Data Validation
This guide covers the full capabilities of Remix V3's data-schema package. It is a standalone validation library inspired by Zod and Valibot, designed for server-side validation with excellent TypeScript inference.
Primitive Types
The building blocks of any schema:
import {
string,
number,
boolean,
bigint,
null_,
undefined_,
symbol,
any,
parse,
} from 'remix/data-schema'
let nameSchema = string() // validates: "hello"
let ageSchema = number() // validates: 42
let activeSchema = boolean() // validates: true
let bigSchema = bigint() // validates: 42n
let nullSchema = null_() // validates: null
let undefSchema = undefined_() // validates: undefined
let symSchema = symbol() // validates: Symbol('x')
let anySchema = any() // validates: anythingParse a value against a schema:
let name = parse(string(), 'hello') // returns 'hello'
let name = parse(string(), 42) // throws a validation errorObject Schemas
Objects are the most common schema type. Each key defines a required property:
import { object, string, number, boolean, parse } from 'remix/data-schema'
let UserSchema = object({
name: string(),
email: string(),
age: number(),
active: boolean(),
})
let user = parse(UserSchema, {
name: 'Jane',
email: 'jane@example.com',
age: 30,
active: true,
})Optional Fields
Use optional() for fields that may be missing:
import { object, string, optional } from 'remix/data-schema'
let ProfileSchema = object({
name: string(),
bio: optional(string()), // string | undefined
website: optional(string()), // string | undefined
})
// Valid -- bio and website are omitted
parse(ProfileSchema, { name: 'Jane' })Nullable Fields
Use nullable() for fields that may be null:
import { object, string, nullable } from 'remix/data-schema'
let PostSchema = object({
title: string(),
subtitle: nullable(string()), // string | null
})
parse(PostSchema, { title: 'Hello', subtitle: null }) // validDefault Values
You can combine optional() with a pipe to provide defaults, or handle it at the application level. A common pattern is:
let CreatePostSchema = object({
title: string(),
published: optional(boolean()),
})
// After parsing, apply defaults
let data = parse(CreatePostSchema, input)
let published = data.published ?? falseArray Schemas
import { array, string, number, object, parse } from 'remix/data-schema'
let tagsSchema = array(string())
// validates: ['remix', 'typescript', 'web']
let numbersSchema = array(number())
// validates: [1, 2, 3]
let usersSchema = array(object({
name: string(),
email: string(),
}))
// validates: [{ name: 'Jane', email: '...' }, ...]Tuple Schemas
Tuples are fixed-length arrays where each position has a specific type:
import { tuple, string, number, boolean } from 'remix/data-schema'
let coordinateSchema = tuple([number(), number()])
// validates: [40.7128, -74.0060]
let recordSchema = tuple([string(), number(), boolean()])
// validates: ['hello', 42, true]Record and Map Schemas
import { record, string, number } from 'remix/data-schema'
// An object with string keys and number values
let scoresSchema = record(string(), number())
// validates: { alice: 100, bob: 85 }import { map, string, number } from 'remix/data-schema'
let cacheSchema = map(string(), number())
// validates: new Map([['a', 1], ['b', 2]])Set Schemas
import { set, string } from 'remix/data-schema'
let tagsSchema = set(string())
// validates: new Set(['remix', 'typescript'])Union Types
A value that can be one of several types:
import { union, string, number, literal } from 'remix/data-schema'
// String or number
let idSchema = union([string(), number()])
parse(idSchema, 'abc') // valid
parse(idSchema, 42) // valid
parse(idSchema, true) // errorDiscriminated Unions (Variants)
For objects that share a common discriminator field:
import { variant, object, string, number, literal } from 'remix/data-schema'
let ShapeSchema = variant('type', [
object({
type: literal('circle'),
radius: number(),
}),
object({
type: literal('rectangle'),
width: number(),
height: number(),
}),
object({
type: literal('triangle'),
base: number(),
height: number(),
}),
])
parse(ShapeSchema, { type: 'circle', radius: 5 }) // valid
parse(ShapeSchema, { type: 'rectangle', width: 3, height: 4 }) // validDiscriminated unions are more efficient than regular unions because the parser checks the discriminator field first, then only validates against the matching variant.
Literal and Enum Types
Literals
A schema that matches a single exact value:
import { literal } from 'remix/data-schema'
let adminSchema = literal('admin')
parse(adminSchema, 'admin') // valid
parse(adminSchema, 'user') // errorEnums
A schema that matches one of several string values:
import { enum_ } from 'remix/data-schema'
let RoleSchema = enum_(['user', 'editor', 'admin'])
parse(RoleSchema, 'editor') // valid
parse(RoleSchema, 'superuser') // error
// TypeScript infers: 'user' | 'editor' | 'admin'Validation Checks
Checks add constraints to a schema. They are applied using .pipe():
import { string, number } from 'remix/data-schema'
import { minLength, maxLength, email, url, regex, min, max, integer, multipleOf } from 'remix/data-schema/checks'String Checks
// Minimum length
let nameSchema = string().pipe(minLength(1, 'Name is required'))
// Maximum length
let bioSchema = string().pipe(maxLength(500, 'Bio must be 500 characters or fewer'))
// Combine checks
let usernameSchema = string().pipe(
minLength(3, 'Username must be at least 3 characters'),
maxLength(20, 'Username must be 20 characters or fewer'),
regex(/^[a-zA-Z0-9_]+$/, 'Username can only contain letters, numbers, and underscores'),
)
// Email validation
let emailSchema = string().pipe(email('Invalid email address'))
// URL validation
let websiteSchema = string().pipe(url('Invalid URL'))Number Checks
// Minimum value
let ageSchema = number().pipe(min(0, 'Age cannot be negative'))
// Maximum value
let ratingSchema = number().pipe(
min(1, 'Rating must be at least 1'),
max(5, 'Rating must be at most 5'),
)
// Integer
let quantitySchema = number().pipe(integer('Quantity must be a whole number'))
// Multiple of
let discountSchema = number().pipe(multipleOf(0.01, 'Discount must be a valid currency amount'))Combining Multiple Checks
Checks are applied in order. If one fails, subsequent checks for that field are skipped:
let passwordSchema = string().pipe(
minLength(8, 'Password must be at least 8 characters'),
maxLength(128, 'Password must be 128 characters or fewer'),
regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),
regex(/[a-z]/, 'Password must contain at least one lowercase letter'),
regex(/[0-9]/, 'Password must contain at least one number'),
)Custom Validation
refine
Add custom validation logic to any schema:
let RegistrationSchema = object({
password: string().pipe(minLength(8)),
confirmPassword: string(),
}).refine(
(data) => data.password === data.confirmPassword,
'Passwords do not match',
)refine receives the parsed value and returns true (valid) or false (invalid). When invalid, the second argument is used as the error message.
pipe with Custom Transforms
Use .pipe() with a transform function to convert data:
// Parse a date string into a Date object
let dateSchema = string().pipe((value) => {
let date = new Date(value)
if (isNaN(date.getTime())) {
return { issues: [{ message: 'Invalid date' }] }
}
return { value: date }
})
// Trim and lowercase an email
let emailSchema = string().pipe(
(value) => ({ value: value.trim().toLowerCase() }),
email(),
)Type Coercion
Form data and query parameters arrive as strings. The coercion module converts string values to their target types before validation:
import { coerceNumber, coerceBoolean, coerceDate } from 'remix/data-schema/coerce'
import { min, integer } from 'remix/data-schema/checks'
// Coerce a string to a number, then validate
let priceSchema = coerceNumber().pipe(min(0))
parse(priceSchema, '19.99') // returns 19.99
// Coerce "true"/"false"/"1"/"0" to boolean
let publishedSchema = coerceBoolean()
parse(publishedSchema, 'true') // returns true
parse(publishedSchema, '0') // returns false
// Coerce a string to a Date
let dateSchema = coerceDate()
parse(dateSchema, '2025-01-15') // returns Date objectWhen to use coercion
Use coercion when validating data from HTML forms, URL query parameters, or any other source where values arrive as strings. Do not use coercion when validating JSON request bodies -- those already have proper types.
FormData Validation
The remix/data-schema/form-data module provides utilities for validating FormData objects directly:
import { formDataSchema } from 'remix/data-schema/form-data'
import { string, number, optional } from 'remix/data-schema'
import { email, minLength, min } from 'remix/data-schema/checks'
import { coerceNumber } from 'remix/data-schema/coerce'
let ContactFormSchema = formDataSchema({
name: string().pipe(minLength(1, 'Name is required')),
email: string().pipe(email('Invalid email')),
age: coerceNumber().pipe(min(0)),
message: string().pipe(
minLength(10, 'Message must be at least 10 characters'),
),
newsletter: optional(string()), // checkbox value
})
// In a route handler
router.map(contactRoutes.action, async ({ context }) => {
let data = context.get(FormData)
let result = parseSafe(ContactFormSchema, data)
if (!result.success) {
return showForm(result.issues)
}
// result.value is fully typed
await sendEmail(result.value.email, result.value.message)
})Error Handling
parseSafe
Unlike parse which throws on failure, parseSafe returns a result object:
import { parseSafe, object, string, number } from 'remix/data-schema'
import { email, min } from 'remix/data-schema/checks'
let schema = object({
email: string().pipe(email()),
age: number().pipe(min(0)),
})
let result = parseSafe(schema, { email: 'not-an-email', age: -5 })
if (!result.success) {
for (let issue of result.issues) {
console.log(issue.path) // e.g., [{ key: 'email' }]
console.log(issue.message) // e.g., 'Invalid email'
}
} else {
console.log(result.value) // The validated data
}Issue Paths
Each issue includes a path that tells you which field failed:
let result = parseSafe(schema, invalidData)
if (!result.success) {
let errorMap: Record<string, string> = {}
for (let issue of result.issues) {
let key = issue.path?.[0]?.key
if (typeof key === 'string') {
errorMap[key] = issue.message
}
}
// errorMap: { email: 'Invalid email', age: 'Must be 0 or greater' }
}For nested objects, the path has multiple entries:
let schema = object({
address: object({
city: string().pipe(minLength(1)),
}),
})
// issue.path would be: [{ key: 'address' }, { key: 'city' }]Custom Error Messages
Pass a message as the second argument to any check:
let schema = object({
username: string().pipe(
minLength(3, 'Username must be at least 3 characters'),
maxLength(20, 'Username cannot exceed 20 characters'),
),
email: string().pipe(
email('Please enter a valid email address'),
),
})Building a Form Error Helper
Here is a reusable function for converting validation issues into a form-friendly format:
function formatErrors(
issues: { path?: { key: string | number }[]; message: string }[],
): Record<string, string> {
let errors: Record<string, string> = {}
for (let issue of issues) {
let key = issue.path?.[0]?.key
if (typeof key === 'string' && !errors[key]) {
errors[key] = issue.message
}
}
return errors
}
// Usage in a handler
let result = parseSafe(schema, values)
if (!result.success) {
return showForm(formatErrors(result.issues), values)
}Type Helpers
InferInput
The type of data that a schema accepts as input:
import type { InferInput } from 'remix/data-schema'
let UserSchema = object({
name: string(),
age: number(),
bio: optional(string()),
})
type UserInput = InferInput<typeof UserSchema>
// { name: string; age: number; bio?: string | undefined }InferOutput
The type of data that a schema produces after parsing (may differ from input if transforms are used):
import type { InferOutput } from 'remix/data-schema'
type UserOutput = InferOutput<typeof UserSchema>
// { name: string; age: number; bio?: string | undefined }When using transforms in .pipe(), the output type may differ from the input type:
let dateStringSchema = string().pipe((value) => ({
value: new Date(value),
}))
type Input = InferInput<typeof dateStringSchema> // string
type Output = InferOutput<typeof dateStringSchema> // DateStandard Schema v1 Compatibility
Remix's data-schema package implements the Standard Schema v1 specification. This means any library that accepts Standard Schema validators can use Remix schemas directly:
import { object, string } from 'remix/data-schema'
import { email } from 'remix/data-schema/checks'
let schema = object({
email: string().pipe(email()),
})
// schema implements the StandardSchemaV1 interface
// Pass it to any library that accepts Standard SchemaPractical Patterns
Reusable Field Schemas
Define common field schemas once and reuse them:
import { string, number } from 'remix/data-schema'
import { email, minLength, min, max } from 'remix/data-schema/checks'
// Common fields
let emailField = string().pipe(email('Invalid email address'))
let passwordField = string().pipe(minLength(8, 'Password must be at least 8 characters'))
let nameField = string().pipe(minLength(1, 'Name is required'))
let priceField = number().pipe(min(0, 'Price cannot be negative'))
let ratingField = number().pipe(min(1), max(5))
// Use in schemas
let LoginSchema = object({
email: emailField,
password: passwordField,
})
let RegisterSchema = object({
name: nameField,
email: emailField,
password: passwordField,
})Partial Schemas for Updates
When updating a record, not all fields are required. Create a partial version:
let CreateBookSchema = object({
title: string().pipe(minLength(1)),
author: string().pipe(minLength(1)),
price: number().pipe(min(0)),
genre: enum_(['fiction', 'non-fiction', 'sci-fi']),
})
let UpdateBookSchema = object({
title: optional(string().pipe(minLength(1))),
author: optional(string().pipe(minLength(1))),
price: optional(number().pipe(min(0))),
genre: optional(enum_(['fiction', 'non-fiction', 'sci-fi'])),
})API Request Validation
router.map(createPostRoute, async ({ request }) => {
let body: unknown
try {
body = await request.json()
} catch {
return new Response('Invalid JSON', { status: 400 })
}
let result = parseSafe(CreatePostSchema, body)
if (!result.success) {
return new Response(JSON.stringify({ errors: result.issues }), {
status: 422,
headers: { 'Content-Type': 'application/json' },
})
}
let post = await db.create(posts, result.value)
return new Response(JSON.stringify(post), {
status: 201,
headers: { 'Content-Type': 'application/json' },
})
})Related
- Tutorial: Forms & Mutations -- Form validation in practice
- Database Guide -- Storing validated data
- Security Guide -- Input validation as a security measure