Tiny, standards-aligned data validation for Remix and the wider TypeScript ecosystem.
- Standard Schema v1 compatible
- Sync-first, minimal API surface
- Runs anywhere JavaScript runs (browser, Node.js, Bun, Deno, Workers)
import { enum_, literal, number, object, parse, string, variant } from '@remix-run/data-schema'
import { email, maxLength, min, minLength } from '@remix-run/data-schema/checks'
import * as coerce from '@remix-run/data-schema/coerce'
let User = object({
id: string(),
email: string().pipe(email()),
username: string().pipe(minLength(3), maxLength(20)),
age: coerce.number().pipe(min(13)),
role: enum_(['admin', 'member', 'guest']),
flags: object({
beta: coerce.boolean(),
}),
})
let Event = variant('type', {
created: object({ type: literal('created'), id: string() }),
updated: object({ type: literal('updated'), id: string(), version: number() }),
})
let user = parse(User, {
id: 'u1',
email: 'ada@example.com',
username: 'ada',
age: '37',
role: 'admin',
flags: { beta: 'true' },
})
let event = parse(Event, { type: 'created', id: 'evt_1' })Use parse() when you want a typed value or an exception.
import { object, string, number, parse } from '@remix-run/data-schema'
let User = object({ name: string(), age: number() })
let user = parse(User, { name: 'Ada', age: 37 })Use parseSafe() when you prefer explicit branching over exceptions.
import { object, string, number, parseSafe } from '@remix-run/data-schema'
let User = object({ name: string(), age: number() })
let result = parseSafe(User, input)
if (!result.success) {
// result.issues — array of { message, path? }
} else {
let user = result.value
}Both parse and parseSafe accept any Standard Schema v1 schema, not just data-schema's own schemas. You can pass a Zod, Valibot, or ArkType schema and they'll work.
For FormData and URLSearchParams, use the remix/data-schema/form-data helpers to build
schemas that plug into the same parse() / parseSafe() flow:
import * as s from 'remix/data-schema'
import * as f from 'remix/data-schema/form-data'
import * as checks from 'remix/data-schema/checks'
import * as coerce from 'remix/data-schema/coerce'
let Login = f.object({
email: f.field(coerce.string().pipe(checks.email())),
password: f.field(s.string().pipe(checks.minLength(8))),
})
let credentials = s.parse(Login, await request.formData())
let filters = s.parse(
f.object({
query: f.field(s.defaulted(s.string(), '')),
tags: f.fields(s.array(s.string())),
}),
new URL(request.url).searchParams,
)f.object(...) is the root schema for FormData and URLSearchParams.
Use f.field(...) for one text value, f.fields(...) for repeated text values,
f.file(...) for one uploaded file, and f.files(...) for repeated files.
When you want a fallback value, prefer s.defaulted(s.string(), '').
File helpers are intended for FormData; URLSearchParams only supports text values.
You can also customize built-in validation messages with errorMap:
import { object, parseSafe, string } from '@remix-run/data-schema'
import { minLength } from '@remix-run/data-schema/checks'
let User = object({
name: string(),
username: string().pipe(minLength(3)),
})
let result = parseSafe(User, input, {
locale: 'es',
errorMap(context) {
if (context.code === 'type.string') {
return 'Se esperaba texto'
}
if (context.code === 'string.min_length') {
return (
'Debe tener al menos ' + String((context.values as { min: number }).min) + ' caracteres'
)
}
},
})errorMap receives { code, defaultMessage, path, values, input, locale }.
Return undefined to keep the default message.
import { string, number, boolean, bigint, symbol, null_, undefined_ } from '@remix-run/data-schema'
string() // validates typeof === 'string'
number() // validates finite numbers (rejects NaN, Infinity)
boolean() // validates typeof === 'boolean'
bigint() // validates typeof === 'bigint'
symbol() // validates typeof === 'symbol'
null_() // validates value === null
undefined_() // validates value === undefinedimport { literal, enum_, union } from '@remix-run/data-schema'
// Exact value match
let yes = literal('yes')
// One of several allowed values
let Status = enum_(['active', 'inactive', 'pending'])
// First schema that matches wins
let StringOrNumber = union([string(), number()])import { object, string, number, optional, defaulted } from '@remix-run/data-schema'
let User = object({
name: string(),
bio: optional(string()), // accepts undefined
role: defaulted(string(), 'user'), // fills in 'user' when undefined
age: number(),
})Unknown keys are stripped by default. Change this with unknownKeys:
object({ name: string() }, { unknownKeys: 'passthrough' }) // keeps unknown keys
object({ name: string() }, { unknownKeys: 'error' }) // rejects unknown keysimport { array, tuple, record, map, set, string, number, boolean } from '@remix-run/data-schema'
array(number()) // number[]
tuple([string(), number(), boolean()]) // [string, number, boolean]
record(string(), number()) // Record<string, number>
map(string(), number()) // Map<string, number>
set(number()) // Set<number>import { nullable, optional, defaulted, string, number } from '@remix-run/data-schema'
nullable(string()) // string | null
optional(number()) // number | undefined
defaulted(string(), 'n/a') // fills 'n/a' when undefinedimport { instanceof_, object } from '@remix-run/data-schema'
let Schema = object({
created: instanceof_(Date),
pattern: instanceof_(RegExp),
})Accept any value without validation. Useful when part of a structure is opaque.
import { any, object, string } from '@remix-run/data-schema'
let Envelope = object({
type: string(),
payload: any(),
})Add domain-specific validation logic inline. The predicate runs after the schema validates.
import { number, string, object } from '@remix-run/data-schema'
let Profile = object({
username: string().refine((s) => s.length >= 3, 'Too short'),
age: number().refine((n) => n >= 18, 'Must be an adult'),
})Compose reusable Check objects for common constraints.
import { object, string, number } from '@remix-run/data-schema'
import { minLength, maxLength, email, min, max } from '@remix-run/data-schema/checks'
let Credentials = object({
username: string().pipe(minLength(3), maxLength(20)),
email: string().pipe(email()),
age: number().pipe(min(13), max(130)),
})Built-in checks: minLength, maxLength, email, url, min, max.
Turn stringly-typed inputs (like form data or query strings) into real types at the schema boundary.
import { object, parse } from '@remix-run/data-schema'
import * as coerce from '@remix-run/data-schema/coerce'
let Query = object({
page: coerce.number(),
includeArchived: coerce.boolean(),
since: coerce.date(),
limit: coerce.bigint(),
search: coerce.string(),
})
let query = parse(Query, {
page: '2',
includeArchived: 'true',
since: '2025-01-01',
limit: '100',
search: 42,
})Pick the right schema based on a discriminator property.
import { literal, number, object, string, variant } from '@remix-run/data-schema'
let Event = variant('type', {
created: object({ type: literal('created'), id: string() }),
updated: object({ type: literal('updated'), id: string(), version: number() }),
})Model trees and self-referencing structures. lazy() defers schema resolution to avoid circular references.
import { array, object, string } from '@remix-run/data-schema'
import { lazy } from '@remix-run/data-schema/lazy'
import type { Schema } from '@remix-run/data-schema'
type TreeNode = { id: string; children: TreeNode[] }
let Node: Schema<unknown, TreeNode> = lazy(() => object({ id: string(), children: array(Node) }))By default, validation collects all issues in a single pass. To stop at the first issue, enable abortEarly.
import { object, string, number, parseSafe } from '@remix-run/data-schema'
let result = parseSafe(
object({ name: string(), age: number() }),
{ name: 123, age: 'x' },
{ abortEarly: true },
)
if (!result.success) {
console.log(result.issues) // only the first issue
}Extract input and output types from any Standard Schema-compatible schema.
import { object, string, number } from '@remix-run/data-schema'
import type { InferInput, InferOutput } from '@remix-run/data-schema'
let User = object({ name: string(), age: number() })
type UserInput = InferInput<typeof User> // unknown
type UserOutput = InferOutput<typeof User> // { name: string; age: number }Build custom schemas using createSchema, createIssue, and fail. These are the same primitives used internally by every built-in schema.
import { createSchema, createIssue, fail } from '@remix-run/data-schema'
import type { Schema } from '@remix-run/data-schema'
// A schema that validates a non-empty trimmed string
function trimmedString(): Schema<unknown, string> {
return createSchema(function validate(value, context) {
if (typeof value !== 'string') {
return fail('Expected string', context.path)
}
let trimmed = value.trim()
if (trimmed.length === 0) {
return fail('Expected non-empty string', context.path)
}
return { value: trimmed }
})
}
// A schema that validates a [lat, lng] coordinate pair
function latLng(): Schema<unknown, [number, number]> {
return createSchema(function validate(value, context) {
if (!Array.isArray(value) || value.length !== 2) {
return fail('Expected [lat, lng] pair', context.path)
}
let issues = []
let [lat, lng] = value
if (typeof lat !== 'number' || lat < -90 || lat > 90) {
issues.push(createIssue('Latitude must be between -90 and 90', [...context.path, 0]))
}
if (typeof lng !== 'number' || lng < -180 || lng > 180) {
issues.push(createIssue('Longitude must be between -180 and 180', [...context.path, 1]))
}
if (issues.length > 0) {
return { issues }
}
return { value: [lat, lng] }
})
}The validator function receives the raw value and a context with the current path and options. Return { value } on success or { issues: [...] } on failure. The returned schema is fully Standard Schema v1-compatible and supports .pipe() and .refine() out of the box.
See LICENSE