Documentation Index
Fetch the complete documentation index at: https://mintlify.com/remix-run/remix/llms.txt
Use this file to discover all available pages before exploring further.
The data-schema package provides tiny, standards-aligned data validation for Remix applications. It’s Standard Schema v1 compatible, sync-first, and runs anywhere JavaScript runs.
Installation
Parsing
parse()
Use parse() when you want a typed value or an exception:
import { object, string, number, parse } from 'remix/data-schema'
let User = object({ name: string(), age: number() })
let user = parse(User, { name: 'Ada', age: 37 })
// user: { name: string; age: number }
parseSafe()
Use parseSafe() when you prefer explicit branching over exceptions:
import { object, string, number, parseSafe } from 'remix/data-schema'
let User = object({ name: string(), age: number() })
let result = parseSafe(User, input)
if (!result.success) {
// result.issues — array of { message, path? }
console.error(result.issues)
} else {
let user = result.value
}
Primitive Types
import {
string,
number,
boolean,
bigint,
symbol,
null_,
undefined_,
} from 'remix/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 === undefined
Objects
import { object, string, number, optional, defaulted } from 'remix/data-schema'
let User = object({
name: string(),
bio: optional(string()), // accepts undefined
role: defaulted(string(), 'user'), // fills in 'user' when undefined
age: number(),
})
type User = InferOutput<typeof User>
// { name: string; bio?: string; role: string; age: number }
Unknown Keys
By default, unknown keys are stripped. Change this with unknownKeys:
object({ name: string() }, { unknownKeys: 'passthrough' }) // keeps unknown keys
object({ name: string() }, { unknownKeys: 'error' }) // rejects unknown keys
Collections
import { array, tuple, record, map, set, string, number, boolean } from 'remix/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>
Literals, Enums, and Unions
import { literal, enum_, union } from 'remix/data-schema'
// Exact value match
let yes = literal('yes')
// One of several allowed values
let Status = enum_(['active', 'inactive', 'pending'] as const)
// First schema that matches wins
let StringOrNumber = union([string(), number()])
Validation Checks
Compose reusable checks with .pipe():
import { object, string, number } from 'remix/data-schema'
import { minLength, maxLength, email, min, max } from 'remix/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(min) - String/array minimum length
maxLength(max) - String/array maximum length
email() - Valid email format
url() - Valid URL format
min(value) - Number/bigint minimum value
max(value) - Number/bigint maximum value
Custom Checks with .refine()
Add domain-specific validation inline:
import { number, string, object } from 'remix/data-schema'
let Profile = object({
username: string().refine((s) => s.length >= 3, 'Too short'),
age: number().refine((n) => n >= 18, 'Must be an adult'),
})
Coercion
Turn stringly-typed inputs (like form data or query strings) into real types:
import { object, parse } from 'remix/data-schema'
import * as coerce from 'remix/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,
})
// query: { page: 2, includeArchived: true, since: Date, limit: 100n, search: '42' }
Discriminated Unions
Pick the right schema based on a discriminator property:
import { literal, number, object, string, variant } from 'remix/data-schema'
let Event = variant('type', {
created: object({ type: literal('created'), id: string() }),
updated: object({ type: literal('updated'), id: string(), version: number() }),
})
let event = parse(Event, { type: 'created', id: 'evt_1' })
// event: { type: 'created'; id: string } | { type: 'updated'; id: string; version: number }
Recursive Schemas
Model trees and self-referencing structures with lazy():
import { array, object, string } from 'remix/data-schema'
import { lazy } from 'remix/data-schema/lazy'
import type { Schema } from 'remix/data-schema'
type TreeNode = { id: string; children: TreeNode[] }
let Node: Schema<unknown, TreeNode> = lazy(() =>
object({ id: string(), children: array(Node) })
)
Custom Error Messages
Customize validation messages with errorMap:
import { object, parseSafe, string } from 'remix/data-schema'
import { minLength } from 'remix/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 }.
Abort Early
By default, validation collects all issues. Stop at the first issue with abortEarly:
import { object, string, number, parseSafe } from 'remix/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
}
Type Inference
Extract input and output types from schemas:
import { object, string, number } from 'remix/data-schema'
import type { InferInput, InferOutput } from 'remix/data-schema'
let User = object({ name: string(), age: number() })
type UserInput = InferInput<typeof User> // unknown
type UserOutput = InferOutput<typeof User> // { name: string; age: number }
Table Validation
Integrate schema validation with data-table:
import { column as c, table } from 'remix/data-table'
import { object, string, parse } from 'remix/data-schema'
import { email } from 'remix/data-schema/checks'
let users = table({
name: 'users',
columns: {
id: c.uuid(),
email: c.varchar(255),
},
validate({ value }) {
let schema = object({
email: string().pipe(email()),
})
try {
let validated = parse(schema, value)
return { value: validated }
} catch (error) {
return { issues: [{ message: error.message }] }
}
},
})
Custom Schemas
Build custom schemas using createSchema, createIssue, and fail:
import { createSchema, createIssue, fail } from 'remix/data-schema'
import type { Schema } from 'remix/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] }
})
}