npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@remix-run/data-schema

v0.1.0

Published

Tiny, standards-aligned schema validation

Readme

data-schema

Tiny, standards-aligned data validation for Remix and the wider TypeScript ecosystem.

  • Standard Schema v1 compatible
  • Sync-first, minimal API surface
  • Runs anywhere JavaScript runs (browser, Node.js, Bun, Deno, Workers)

Quick start

import { enum_, literal, number, object, parse, string, variant } from '@remix-run/data-schema'
import { email, maxLength, min, minLength } from '@remix-run/data-schema/checks'
import * as coerce from '@remix-run/data-schema/coerce'

let User = object({
  id: string(),
  email: string().pipe(email()),
  username: string().pipe(minLength(3), maxLength(20)),
  age: coerce.number().pipe(min(13)),
  role: enum_(['admin', 'member', 'guest'] as const),
  flags: object({
    beta: coerce.boolean(),
  }),
})

let Event = variant('type', {
  created: object({ type: literal('created'), id: string() }),
  updated: object({ type: literal('updated'), id: string(), version: number() }),
})

let user = parse(User, {
  id: 'u1',
  email: '[email protected]',
  username: 'ada',
  age: '37',
  role: 'admin',
  flags: { beta: 'true' },
})

let event = parse(Event, { type: 'created', id: 'evt_1' })

Parsing

Use parse() when you want a typed value or an exception.

import { object, string, number, parse } from '@remix-run/data-schema'

let User = object({ name: string(), age: number() })

let user = parse(User, { name: 'Ada', age: 37 })

Use parseSafe() when you prefer explicit branching over exceptions.

import { object, string, number, parseSafe } from '@remix-run/data-schema'

let User = object({ name: string(), age: number() })

let result = parseSafe(User, input)

if (!result.success) {
  // result.issues — array of { message, path? }
} else {
  let user = result.value
}

Both parse and parseSafe accept any Standard Schema v1 schema, not just data-schema's own schemas. You can pass a Zod, Valibot, or ArkType schema and they'll work.

You can also customize built-in validation messages with errorMap:

import { object, parseSafe, string } from '@remix-run/data-schema'
import { minLength } from '@remix-run/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 }. Return undefined to keep the default message.

Primitives

import { string, number, boolean, bigint, symbol, null_, undefined_ } from '@remix-run/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

Literals, enums, and unions

import { literal, enum_, union } from '@remix-run/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()])

Objects

import { object, string, number, optional, defaulted } from '@remix-run/data-schema'

let User = object({
  name: string(),
  bio: optional(string()), // accepts undefined
  role: defaulted(string(), 'user'), // fills in 'user' when undefined
  age: number(),
})

Unknown keys are stripped by default. 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-run/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>

Modifiers

import { nullable, optional, defaulted, string, number } from '@remix-run/data-schema'

nullable(string()) // string | null
optional(number()) // number | undefined
defaulted(string(), 'n/a') // fills 'n/a' when undefined

Instance checks

import { instanceof_, object } from '@remix-run/data-schema'

let Schema = object({
  created: instanceof_(Date),
  pattern: instanceof_(RegExp),
})

Any

Accept any value without validation. Useful when part of a structure is opaque.

import { any, object, string } from '@remix-run/data-schema'

let Envelope = object({
  type: string(),
  payload: any(),
})

Custom rules with .refine()

Add domain-specific validation logic inline. The predicate runs after the schema validates.

import { number, string, object } from '@remix-run/data-schema'

let Profile = object({
  username: string().refine((s) => s.length >= 3, 'Too short'),
  age: number().refine((n) => n >= 18, 'Must be an adult'),
})

Validation pipelines with .pipe()

Compose reusable Check objects for common constraints.

import { object, string, number } from '@remix-run/data-schema'
import { minLength, maxLength, email, min, max } from '@remix-run/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, maxLength, email, url, min, max.

Coercing input values

Turn stringly-typed inputs (like form data or query strings) into real types at the schema boundary.

import { object, parse } from '@remix-run/data-schema'
import * as coerce from '@remix-run/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,
})

Discriminated unions

Pick the right schema based on a discriminator property.

import { literal, number, object, string, variant } from '@remix-run/data-schema'

let Event = variant('type', {
  created: object({ type: literal('created'), id: string() }),
  updated: object({ type: literal('updated'), id: string(), version: number() }),
})

Recursive schemas

Model trees and self-referencing structures. lazy() defers schema resolution to avoid circular references.

import { array, object, string } from '@remix-run/data-schema'
import { lazy } from '@remix-run/data-schema/lazy'
import type { Schema } from '@remix-run/data-schema'

type TreeNode = { id: string; children: TreeNode[] }

let Node: Schema<unknown, TreeNode> = lazy(() => object({ id: string(), children: array(Node) }))

Aborting early

By default, validation collects all issues in a single pass. To stop at the first issue, enable abortEarly.

import { object, string, number, parseSafe } from '@remix-run/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 any Standard Schema-compatible schema.

import { object, string, number } from '@remix-run/data-schema'
import type { InferInput, InferOutput } from '@remix-run/data-schema'

let User = object({ name: string(), age: number() })

type UserInput = InferInput<typeof User> // unknown
type UserOutput = InferOutput<typeof User> // { name: string; age: number }

Extending data-schema

Build custom schemas using createSchema, createIssue, and fail. These are the same primitives used internally by every built-in schema.

import { createSchema, createIssue, fail } from '@remix-run/data-schema'
import type { Schema } from '@remix-run/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] }
  })
}

The validator function receives the raw value and a context with the current path and options. Return { value } on success or { issues: [...] } on failure. The returned schema is fully Standard Schema v1-compatible and supports .pipe() and .refine() out of the box.

License

See LICENSE