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
npm install remixOr install the standalone package:
npm install @remix-run/data-schemaImports
Main Entry
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
import { email, url, min, max, minLength, maxLength } from 'remix/data-schema/checks'Coercions
import {
string as coerceString,
number as coerceNumber,
boolean as coerceBoolean,
bigint as coerceBigint,
date as coerceDate,
} from 'remix/data-schema/coerce'Form Data
import { object, field, fields, file, files } from 'remix/data-schema/form-data'Lazy Validation
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.
import { parse, string } from 'remix/data-schema'
let name = parse(string(), 'Alice') // 'Alice'
let name = parse(string(), 42) // throws ValidationErrorparseSafe(schema, value, options?)
Validates a value without throwing. Returns a discriminated result.
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
| Option | Type | Description |
|---|---|---|
abortEarly | boolean | Stop validation after the first issue. |
errorMap | ErrorMap | Function to customize issue messages. |
locale | string | Locale hint passed to the error map. |
ValidationError
Extends Error with an issues property containing validation issues.
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.
parse(string(), 'hello') // 'hello'
parse(string(), 42) // throws: "Expected string"number()
Validates that the value is a finite number (excludes NaN and Infinity).
parse(number(), 42) // 42
parse(number(), NaN) // throws: "Expected number"boolean()
Validates that the value is a boolean.
parse(boolean(), true) // truebigint()
Validates that the value is a bigint.
parse(bigint(), 42n) // 42nsymbol()
Validates that the value is a symbol.
parse(symbol(), Symbol()) // Symbol()null_()
Validates that the value is null.
parse(null_(), null) // nullundefined_()
Validates that the value is undefined.
parse(undefined_(), undefined) // undefinedany()
Accepts any value without validation.
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.
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
| Option | Type | Default | Description |
|---|---|---|---|
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.
let tags = parse(array(string()), ['remix', 'web'])
// ['remix', 'web']tuple(items)
Validates a fixed-length tuple where each position has its own schema.
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.
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.
let m = new Map([['a', 1], ['b', 2]])
let result = parse(map(string(), number()), m)set(valueSchema)
Validates a Set with typed values.
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.
let stringOrNumber = union([string(), number()])
parse(stringOrNumber, 'hello') // 'hello'
parse(stringOrNumber, 42) // 42variant(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.
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.
parse(literal('admin'), 'admin') // 'admin'
parse(literal(42), 42) // 42enum_(values)
Accepts one of the given values using strict equality.
let role = enum_(['admin', 'user', 'guest'] as const)
parse(role, 'admin') // 'admin'
parse(role, 'other') // throwsinstanceof_(constructor)
Validates that the value is an instance of a class.
parse(instanceof_(Date), new Date()) // Date
parse(instanceof_(Date), 'not a date') // throwsWrappers
optional(schema)
Allows undefined in addition to the wrapped schema's input.
let optionalName = optional(string())
parse(optionalName, 'Alice') // 'Alice'
parse(optionalName, undefined) // undefinednullable(schema)
Allows null in addition to the wrapped schema's input.
let nullableName = nullable(string())
parse(nullableName, 'Alice') // 'Alice'
parse(nullableName, null) // nulldefaulted(schema, defaultValue)
Provides a default value when the input is undefined. The default can be a value or a function.
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.
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.
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:
import { email, url, min, max, minLength, maxLength } from 'remix/data-schema/checks'| Check | Input Type | Description |
|---|---|---|
minLength(length) | string | Requires at least length characters. |
maxLength(length) | string | Requires at most length characters. |
email() | string | Validates email format. |
url() | string | Validates URL format (using new URL()). |
min(limit) | number | Requires value >= limit. |
max(limit) | number | Requires value <= limit. |
Usage
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.
import * as coerce from 'remix/data-schema/coerce'| Coercion | Accepts | Produces | Description |
|---|---|---|---|
coerce.string() | string, number, boolean, bigint, symbol | string | Stringifies primitives. |
coerce.number() | number, numeric string | number | Parses strings with Number(). Rejects NaN/Infinity. |
coerce.boolean() | boolean, "true"/"false" | boolean | Parses case-insensitive boolean strings. |
coerce.bigint() | bigint, integer number, integer string | bigint | Parses with BigInt(). |
coerce.date() | Date, date string | Date | Parses with new Date(). |
Example
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.
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.
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
| Helper | Description |
|---|---|
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:
| Option | Type | Description |
|---|---|---|
name | string | The form field name to read. Defaults to the object key. |
schema | Schema | The schema used to validate the parsed value(s). |
Types
| Type | Description |
|---|---|
FormDataSource | FormData | URLSearchParams |
FormDataSchema | Record<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.
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.
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) // throwsThe validator receives:
value--- The input value to validate.context.path--- The current path in the input structure (for nested error reporting).context.options--- TheParseOptionspassed toparse()orparseSafe().
It must return either { value: output } on success or { issues: Issue[] } on failure.
createIssue(message, path)
Creates a Standard Schema issue object.
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.
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:
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
| Type | Description |
|---|---|
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(). |
Issue | A Standard Schema v1 validation issue with message and optional path. |
ValidationResult<output> | { value: output } or { issues: Issue[] }. |
ParseOptions | Options for parse() and parseSafe(). |
ErrorMap | Function to customize issue messages: (context) => string | undefined. |
ErrorMapContext | Context passed to error maps: { code, defaultMessage, path?, values?, input, locale? }. |
Inferring Types
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 }Related
- Validation Guide --- Practical validation patterns.
- Forms & Mutations --- Form handling with schema validation.
- data-table --- The relational query toolkit (uses data-schema for table validation).