Data Schema Tutorial
In this tutorial you will build validation schemas for three real-world scenarios:
- User registration --- validate an object with checks.
- Form data --- parse
FormDatafrom an HTML form. - API input --- validate and coerce JSON request bodies.
Prerequisites
Install Remix:
npm install remixPart 1: User Registration
Step 1 --- Define the Schema
Start with an object schema. Each field uses a primitive type and optional checks:
import { object, string, number, optional, parse } from 'remix/data-schema'
import { email, minLength, maxLength, min, max } from 'remix/data-schema/checks'
let registrationSchema = object({
username: string().pipe(minLength(3), maxLength(20)),
email: string().pipe(email()),
password: string().pipe(minLength(8)),
age: number().pipe(min(13), max(150)),
bio: optional(string().pipe(maxLength(500))),
})What is .pipe()?
.pipe() chains one or more checks onto a schema. Checks run after the base type validates. You can compose multiple checks: string().pipe(minLength(1), maxLength(100)).
Step 2 --- Validate Input
Use parse() to validate. It throws a ValidationError if the input is invalid:
try {
let user = parse(registrationSchema, {
username: 'alice',
email: 'alice@example.com',
password: 'securepass123',
age: 25,
})
// user is fully typed: { username: string; email: string; ... }
} catch (error) {
// error is a ValidationError with .issues
}Step 3 --- Collect Errors with parseSafe
For form UIs, use parseSafe() to collect all validation errors at once:
import { parseSafe } from 'remix/data-schema'
let result = parseSafe(registrationSchema, {
username: 'ab', // too short
email: 'not-an-email', // invalid format
password: '123', // too short
age: 10, // under minimum
})
if (!result.success) {
for (let issue of result.issues) {
console.log(`${issue.path?.join('.')}: ${issue.message}`)
}
// username: Minimum length is 3
// email: Invalid email
// password: Minimum length is 8
// age: Minimum value is 13
}Step 4 --- Custom Validation with .refine()
Add inline validation logic with .refine():
let passwordSchema = string()
.pipe(minLength(8))
.refine(
(pw) => /[A-Z]/.test(pw) && /[0-9]/.test(pw),
'Password must contain at least one uppercase letter and one number',
)
let strongRegistration = object({
username: string().pipe(minLength(3)),
email: string().pipe(email()),
password: passwordSchema,
})Step 5 --- Nested Objects and Arrays
Schemas compose naturally. Validate addresses and a list of tags:
import { array } from 'remix/data-schema'
let addressSchema = object({
street: string().pipe(minLength(1)),
city: string().pipe(minLength(1)),
zip: string().pipe(minLength(5), maxLength(10)),
})
let profileSchema = object({
username: string().pipe(minLength(3)),
email: string().pipe(email()),
address: addressSchema,
tags: array(string().pipe(minLength(1))),
})
let profile = parse(profileSchema, {
username: 'alice',
email: 'alice@example.com',
address: { street: '123 Main St', city: 'Springfield', zip: '62701' },
tags: ['developer', 'remix'],
})Part 2: Form Data Parsing
HTML forms submit values as strings via FormData. The remix/data-schema/form-data module reads and validates form fields directly.
Step 1 --- Define a FormData Schema
import * as fd from 'remix/data-schema/form-data'
import * as coerce from 'remix/data-schema/coerce'
import { string } from 'remix/data-schema'
import { minLength, email } from 'remix/data-schema/checks'
let contactFormSchema = fd.object({
name: fd.field({ schema: string().pipe(minLength(1)) }),
email: fd.field({ schema: string().pipe(email()) }),
message: fd.field({ schema: string().pipe(minLength(10)) }),
})Field name inference
By default, fd.field() uses the object key as the form field name. If your HTML uses a different name, pass { name: 'html-field-name', schema: ... }.
Step 2 --- Validate in a Request Handler
import { parse, parseSafe } from 'remix/data-schema'
export async function handleContact(request: Request): Response {
let formData = await request.formData()
let result = parseSafe(contactFormSchema, formData)
if (!result.success) {
// Return validation errors to the form
return new Response(JSON.stringify({ errors: result.issues }), {
status: 422,
headers: { 'Content-Type': 'application/json' },
})
}
// result.value is typed: { name: string; email: string; message: string }
await sendContactEmail(result.value)
return Response.redirect('/contact/thanks', 303)
}Step 3 --- Coerce Numeric Fields
Form fields are always strings. Use coercions to convert them to numbers or booleans:
let orderFormSchema = fd.object({
productId: fd.field({ schema: coerce.number() }),
quantity: fd.field({ schema: coerce.number().pipe(min(1), max(99)) }),
giftWrap: fd.field({ schema: coerce.boolean() }),
})When the form submits quantity: "3" and giftWrap: "true", the coercion schemas produce quantity: 3 and giftWrap: true.
Step 4 --- File Uploads
Use fd.file() and fd.files() for file inputs:
let uploadSchema = fd.object({
title: fd.field({ schema: string().pipe(minLength(1)) }),
avatar: fd.file({ schema: instanceof_(File) }),
})
let formData = await request.formData()
let data = parse(uploadSchema, formData)
// data.avatar is a FilePart 3: API Input Validation
Step 1 --- Validate JSON Request Bodies
Parse the JSON body and validate it with a schema:
import { object, string, number, array, boolean, parse } from 'remix/data-schema'
import { minLength, min } from 'remix/data-schema/checks'
let createOrderSchema = object({
customerId: number().pipe(min(1)),
items: array(object({
productId: number().pipe(min(1)),
quantity: number().pipe(min(1)),
})).pipe(minLength(1)),
rushDelivery: boolean(),
})
export async function handleCreateOrder(request: Request): Response {
let body = await request.json()
try {
let order = parse(createOrderSchema, body)
// order is fully typed
await processOrder(order)
return new Response(JSON.stringify({ ok: true }), { status: 201 })
} catch (error) {
return new Response(JSON.stringify({ error: error.issues }), { status: 422 })
}
}Step 2 --- Coerce URL Search Parameters
Query strings are always strings. Use coercions for type-safe parameter parsing:
import * as coerce from 'remix/data-schema/coerce'
import { object, optional, parse } from 'remix/data-schema'
import { min, max } from 'remix/data-schema/checks'
let paginationSchema = object({
page: optional(coerce.number().pipe(min(1))),
limit: optional(coerce.number().pipe(min(1), max(100))),
search: optional(coerce.string()),
})
export function handleList(request: Request): Response {
let url = new URL(request.url)
let params = Object.fromEntries(url.searchParams)
let { page = 1, limit = 20, search } = parse(paginationSchema, params)
// page is number, limit is number, search is string | undefined
}Step 3 --- Union and Variant Types
Validate polymorphic data with union() or the more efficient variant():
import { object, string, number, literal, variant, parse } from 'remix/data-schema'
let eventSchema = variant('type', {
click: object({
type: literal('click'),
x: number(),
y: number(),
}),
keypress: object({
type: literal('keypress'),
key: string(),
}),
scroll: object({
type: literal('scroll'),
offset: number(),
}),
})
let event = parse(eventSchema, { type: 'click', x: 100, y: 200 })
// TypeScript knows event is { type: 'click'; x: number; y: number }variant vs. union
variant() uses a discriminator key to select the right schema immediately. union() tries each schema in order until one matches. Use variant() when your data has a type tag --- it is faster and gives better error messages.
What You Learned
- Primitives:
string(),number(),boolean()validate base types. - Objects and arrays:
object()andarray()compose schemas into structures. - Checks:
.pipe(email()),.pipe(minLength(3)),.pipe(min(0))add validation rules. - Custom validation:
.refine()adds inline predicate checks. - parseSafe(): Returns a result object instead of throwing --- useful for collecting form errors.
- FormData parsing:
fd.object()withfd.field()reads and validates form fields directly. - Coercions:
coerce.number(),coerce.boolean()convert strings from forms and query params. - Variants:
variant()validates discriminated unions efficiently.
Related
- Data Schema Overview --- Feature summary and Standard Schema compatibility.
- Data Schema Reference --- Full API reference for every export.
- Validation Guide --- Production validation patterns.
- Forms & Mutations --- Form handling with schema validation.