Skip to content

Data Schema Tutorial

In this tutorial you will build validation schemas for three real-world scenarios:

  1. User registration --- validate an object with checks.
  2. Form data --- parse FormData from an HTML form.
  3. API input --- validate and coerce JSON request bodies.

Prerequisites

Install Remix:

bash
npm install remix

Part 1: User Registration

Step 1 --- Define the Schema

Start with an object schema. Each field uses a primitive type and optional checks:

ts
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:

ts
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:

ts
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():

ts
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:

ts
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

ts
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

ts
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:

ts
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:

ts
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 File

Part 3: API Input Validation

Step 1 --- Validate JSON Request Bodies

Parse the JSON body and validate it with a schema:

ts
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:

ts
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():

ts
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() and array() 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() with fd.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.

Released under the MIT License.