Skip to main content
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

npm i remix

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] }
  })
}

Build docs developers (and LLMs) love