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.