Skip to content

data-schema

The data-schema package provides synchronous schema validation with full TypeScript inference. It implements Standard Schema v1, making it compatible with any library that supports Standard Schema.

Installation

bash
npm install remix

Or install the standalone package:

bash
npm install @remix-run/data-schema

Imports

Main Entry

ts
import {
  // Schema types
  string,
  number,
  boolean,
  bigint,
  symbol,
  null_,
  undefined_,
  any,

  // Composite types
  object,
  array,
  tuple,
  record,
  map,
  set,
  union,
  variant,

  // Literal & enum
  literal,
  enum_,
  instanceof_,

  // Wrappers
  optional,
  nullable,
  defaulted,

  // Parsing
  parse,
  parseSafe,
  ValidationError,
  createIssue,
  fail,

  // Custom schemas
  createSchema,
} from 'remix/data-schema'

Checks

ts
import { email, url, min, max, minLength, maxLength } from 'remix/data-schema/checks'

Coercions

ts
import {
  string as coerceString,
  number as coerceNumber,
  boolean as coerceBoolean,
  bigint as coerceBigint,
  date as coerceDate,
} from 'remix/data-schema/coerce'

Form Data

ts
import { object, field, fields, file, files } from 'remix/data-schema/form-data'

Lazy Validation

ts
import { lazy } from 'remix/data-schema/lazy'

Parsing

parse(schema, value, options?)

Validates a value against a schema. Returns the typed output or throws a ValidationError.

ts
import { parse, string } from 'remix/data-schema'

let name = parse(string(), 'Alice')  // 'Alice'
let name = parse(string(), 42)       // throws ValidationError

parseSafe(schema, value, options?)

Validates a value without throwing. Returns a discriminated result.

ts
import { parseSafe, number } from 'remix/data-schema'

let result = parseSafe(number(), 42)
if (result.success) {
  console.log(result.value)  // 42
} else {
  console.log(result.issues) // ReadonlyArray<Issue>
}

ParseOptions

OptionTypeDescription
abortEarlybooleanStop validation after the first issue.
errorMapErrorMapFunction to customize issue messages.
localestringLocale hint passed to the error map.

ValidationError

Extends Error with an issues property containing validation issues.

ts
import { ValidationError } from 'remix/data-schema'

try {
  parse(schema, value)
} catch (error) {
  if (error instanceof ValidationError) {
    for (let issue of error.issues) {
      console.log(issue.message, issue.path)
    }
  }
}

Schema Types

Primitives

string()

Validates that the value is a string.

ts
parse(string(), 'hello')  // 'hello'
parse(string(), 42)        // throws: "Expected string"

number()

Validates that the value is a finite number (excludes NaN and Infinity).

ts
parse(number(), 42)        // 42
parse(number(), NaN)       // throws: "Expected number"

boolean()

Validates that the value is a boolean.

ts
parse(boolean(), true)     // true

bigint()

Validates that the value is a bigint.

ts
parse(bigint(), 42n)       // 42n

symbol()

Validates that the value is a symbol.

ts
parse(symbol(), Symbol())  // Symbol()

null_()

Validates that the value is null.

ts
parse(null_(), null)       // null

undefined_()

Validates that the value is undefined.

ts
parse(undefined_(), undefined)  // undefined

any()

Accepts any value without validation.

ts
parse(any(), 'anything')   // 'anything'

Composite Types

object(shape, options?)

Validates an object with a fixed shape. By default, unknown keys are stripped from the output.

ts
let userSchema = object({
  name: string(),
  age: number(),
  email: optional(string()),
})

let user = parse(userSchema, { name: 'Alice', age: 30 })
// { name: 'Alice', age: 30, email: undefined }
Object Options
OptionTypeDefaultDescription
unknownKeys'strip' | 'passthrough' | 'error''strip'How to handle keys not in the shape.

array(elementSchema)

Validates an array where each element matches the given schema.

ts
let tags = parse(array(string()), ['remix', 'web'])
// ['remix', 'web']

tuple(items)

Validates a fixed-length tuple where each position has its own schema.

ts
let point = parse(tuple([number(), number()]), [10, 20])
// [10, 20]

record(keySchema, valueSchema)

Validates an object map where all keys and values match the given schemas.

ts
let scores = parse(record(string(), number()), { alice: 100, bob: 85 })
// { alice: 100, bob: 85 }

map(keySchema, valueSchema)

Validates a Map with typed keys and values.

ts
let m = new Map([['a', 1], ['b', 2]])
let result = parse(map(string(), number()), m)

set(valueSchema)

Validates a Set with typed values.

ts
let s = new Set([1, 2, 3])
let result = parse(set(number()), s)

Union & Variant

union(schemas)

Tries each schema in order, returning the first successful result.

ts
let stringOrNumber = union([string(), number()])
parse(stringOrNumber, 'hello')  // 'hello'
parse(stringOrNumber, 42)       // 42

variant(discriminator, variants)

A discriminated union that uses a key to select the correct schema. More efficient than union because it does not try every variant.

ts
let eventSchema = variant('type', {
  click: object({ type: literal('click'), x: number(), y: number() }),
  keypress: object({ type: literal('keypress'), key: string() }),
})

parse(eventSchema, { type: 'click', x: 10, y: 20 })
// { type: 'click', x: 10, y: 20 }

Literals & Enums

literal(value)

Accepts a single literal value using strict equality.

ts
parse(literal('admin'), 'admin')  // 'admin'
parse(literal(42), 42)            // 42

enum_(values)

Accepts one of the given values using strict equality.

ts
let role = enum_(['admin', 'user', 'guest'] as const)
parse(role, 'admin')  // 'admin'
parse(role, 'other')  // throws

instanceof_(constructor)

Validates that the value is an instance of a class.

ts
parse(instanceof_(Date), new Date())  // Date
parse(instanceof_(Date), 'not a date')  // throws

Wrappers

optional(schema)

Allows undefined in addition to the wrapped schema's input.

ts
let optionalName = optional(string())
parse(optionalName, 'Alice')    // 'Alice'
parse(optionalName, undefined)  // undefined

nullable(schema)

Allows null in addition to the wrapped schema's input.

ts
let nullableName = nullable(string())
parse(nullableName, 'Alice')  // 'Alice'
parse(nullableName, null)     // null

defaulted(schema, defaultValue)

Provides a default value when the input is undefined. The default can be a value or a function.

ts
let role = defaulted(string(), 'user')
parse(role, undefined)  // 'user'
parse(role, 'admin')    // 'admin'

// Function default (called each time)
let id = defaulted(string(), () => crypto.randomUUID())

Schema Methods

All schemas support chainable methods:

.pipe(...checks)

Composes one or more reusable checks onto the schema. Checks run after the underlying schema validates.

ts
import { string } from 'remix/data-schema'
import { minLength, maxLength, email } from 'remix/data-schema/checks'

let emailSchema = string().pipe(email())
let nameSchema = string().pipe(minLength(1), maxLength(100))

.refine(predicate, message?)

Adds an inline predicate check. Returns false to fail validation.

ts
let evenNumber = number().refine(
  (n) => n % 2 === 0,
  'Must be an even number',
)

parse(evenNumber, 4)   // 4
parse(evenNumber, 3)   // throws: "Must be an even number"

Checks

Import from remix/data-schema/checks:

ts
import { email, url, min, max, minLength, maxLength } from 'remix/data-schema/checks'
CheckInput TypeDescription
minLength(length)stringRequires at least length characters.
maxLength(length)stringRequires at most length characters.
email()stringValidates email format.
url()stringValidates URL format (using new URL()).
min(limit)numberRequires value >= limit.
max(limit)numberRequires value <= limit.

Usage

ts
let ageSchema = number().pipe(min(0), max(150))
let emailSchema = string().pipe(email())
let urlSchema = string().pipe(url())
let usernameSchema = string().pipe(minLength(3), maxLength(20))

Coercions

Import from remix/data-schema/coerce. These schemas accept loosely-typed input and coerce it to the target type.

ts
import * as coerce from 'remix/data-schema/coerce'
CoercionAcceptsProducesDescription
coerce.string()string, number, boolean, bigint, symbolstringStringifies primitives.
coerce.number()number, numeric stringnumberParses strings with Number(). Rejects NaN/Infinity.
coerce.boolean()boolean, "true"/"false"booleanParses case-insensitive boolean strings.
coerce.bigint()bigint, integer number, integer stringbigintParses with BigInt().
coerce.date()Date, date stringDateParses with new Date().

Example

ts
import * as coerce from 'remix/data-schema/coerce'
import { parse, object } from 'remix/data-schema'

let querySchema = object({
  page: coerce.number(),
  active: coerce.boolean(),
})

// Works with URL search params (string values)
parse(querySchema, { page: '3', active: 'true' })
// { page: 3, active: true }

Form Data Parsing

Import from remix/data-schema/form-data. Provides schemas that read values from FormData or URLSearchParams.

ts
import { object, field, fields, file, files } from 'remix/data-schema/form-data'

object(schema)

Creates a Standard Schema-compatible schema that validates a FormData or URLSearchParams source.

ts
import * as fd from 'remix/data-schema/form-data'
import * as coerce from 'remix/data-schema/coerce'
import { parse, string } from 'remix/data-schema'
import { minLength } from 'remix/data-schema/checks'

let loginSchema = fd.object({
  email: fd.field({ name: 'email', schema: string().pipe(minLength(1)) }),
  password: fd.field({ schema: string().pipe(minLength(8)) }),
})

// In a request handler:
let formData = await request.formData()
let credentials = parse(loginSchema, formData)
// { email: 'alice@example.com', password: '...' }

Field Helpers

HelperDescription
field(options)Reads a single text field. Uses the object key as the field name by default.
fields(options)Reads multiple values for the same field name (returns an array).
file(options)Reads a single File entry from FormData.
files(options)Reads multiple File entries for the same field name.

Options

All helpers accept an options object:

OptionTypeDescription
namestringThe form field name to read. Defaults to the object key.
schemaSchemaThe schema used to validate the parsed value(s).

Types

TypeDescription
FormDataSourceFormData | URLSearchParams
FormDataSchemaRecord<string, FormDataEntrySchema>
FormDataObjectSchema<schema>Schema that validates a FormDataSource.
ParsedFormData<schema>The typed result produced by object().

Lazy Validation

Import from remix/data-schema/lazy. Use lazy() for recursive schemas.

ts
import { lazy } from 'remix/data-schema/lazy'
import { object, string, array, nullable } from 'remix/data-schema'

type Category = {
  name: string
  children: Category[]
}

let categorySchema: Schema<unknown, Category> = object({
  name: string(),
  children: array(lazy(() => categorySchema)),
})

Custom Schemas

createSchema(validator)

Creates a custom schema from a validation function.

ts
import { createSchema, fail } from 'remix/data-schema'

let positiveInteger = createSchema<unknown, number>((value, context) => {
  if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
    return fail('Expected positive integer', context.path, {
      code: 'custom.positive_integer',
      input: value,
      parseOptions: context.options,
    })
  }
  return { value }
})

parse(positiveInteger, 5)   // 5
parse(positiveInteger, -1)  // throws

The validator receives:

  • value --- The input value to validate.
  • context.path --- The current path in the input structure (for nested error reporting).
  • context.options --- The ParseOptions passed to parse() or parseSafe().

It must return either { value: output } on success or { issues: Issue[] } on failure.

createIssue(message, path)

Creates a Standard Schema issue object.

ts
import { createIssue } from 'remix/data-schema'

let issue = createIssue('Value is required', ['user', 'email'])
// { message: 'Value is required', path: ['user', 'email'] }

fail(message, path, options?)

Creates a failure result with a single issue. Supports error mapping via the code option.

ts
import { fail } from 'remix/data-schema'

return fail('Invalid email', context.path, {
  code: 'string.email',
  input: value,
  parseOptions: context.options,
})

Standard Schema v1

All schemas implement Standard Schema v1, making them compatible with any library that supports the spec:

ts
import type { StandardSchemaV1 } from '@standard-schema/spec'

// Every schema satisfies StandardSchemaV1
let schema = string() satisfies StandardSchemaV1<unknown, string>

// Use the standard validate method
let result = schema['~standard'].validate('hello')

Type Helpers

TypeDescription
InferInput<schema>Infers the input type accepted by a schema.
InferOutput<schema>Infers the output type produced by a schema.
Schema<input, output>A Standard Schema v1-compatible schema with .pipe() and .refine().
Check<output>A reusable check for .pipe().
IssueA Standard Schema v1 validation issue with message and optional path.
ValidationResult<output>{ value: output } or { issues: Issue[] }.
ParseOptionsOptions for parse() and parseSafe().
ErrorMapFunction to customize issue messages: (context) => string | undefined.
ErrorMapContextContext passed to error maps: { code, defaultMessage, path?, values?, input, locale? }.

Inferring Types

ts
import type { InferInput, InferOutput } from 'remix/data-schema'

let userSchema = object({
  name: string(),
  age: optional(number()),
})

type UserInput = InferInput<typeof userSchema>
// unknown

type User = InferOutput<typeof userSchema>
// { name: string; age: number | undefined }

Released under the MIT License.