Skip to content

validate

Terminal window
bun add @stopcock/validate

Define a schema, get the TypeScript type for free with Infer<typeof Schema>. JIT-compile it with compile() if you’re validating the same shape on every request.

Errors come back as structured objects with path, message, and value, so you can render them next to form fields without faffing about with .format(). V.transform(schema, fn) lets you validate and convert in one step.

import { V, compile, type Infer } from '@stopcock/validate'
const User = V.object({
name: V.string().min(1),
email: V.string().email(),
age: V.number().int().min(0),
role: V.union(V.literal('admin'), V.literal('user')),
})
type User = Infer<typeof User>
const result = User.validate(input)
if (result.success) {
result.data // User
} else {
result.errors // ValidationError[]
}
// zod
import { z } from 'zod'
const User = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().min(0),
role: z.union([z.literal('admin'), z.literal('user')]),
})
type User = z.infer<typeof User>
// stopcock: same API shape, but with JIT compilation
import { V, compile, type Infer } from '@stopcock/validate'
const User = V.object({
name: V.string().min(1),
email: V.string().email(),
age: V.number().int().min(0),
role: V.union(V.literal('admin'), V.literal('user')),
})
type User = Infer<typeof User>
const Env = V.object({
PORT: V.transform(V.string(), s => parseInt(s, 10)),
DATABASE_URL: V.string().url(),
NODE_ENV: V.union(V.literal('development'), V.literal('production'), V.literal('test')),
API_KEY: V.string().min(32),
DEBUG: V.transform(V.optional(V.string()), s => s === 'true'),
})
const env = compile(Env)(process.env)
if (!env.success) {
console.error('Invalid environment:', env.errors)
process.exit(1)
}
// env.data is fully typed: { PORT: number, DATABASE_URL: string, ... }
const Address = V.object({
street: V.string().min(1),
city: V.string().min(1),
zip: V.string().regex(/^\d{5}$/),
})
const Order = V.object({
items: V.array(V.object({
sku: V.string(),
quantity: V.number().int().min(1),
})).min(1),
shipping: Address,
billing: V.optional(Address),
})
const result = Order.validate(formData)
if (!result.success) {
// result.errors[0].path = ['shipping', 'zip']
// result.errors[0].message = 'string must match /^\d{5}$/'
// Render errors next to the right form fields
}
const SearchParams = V.object({
q: V.string().min(1),
page: V.transform(V.optional(V.string()), s => parseInt(s ?? '1', 10)),
limit: V.transform(V.optional(V.string()), s => Math.min(parseInt(s ?? '20', 10), 100)),
sort: V.optional(V.union(V.literal('asc'), V.literal('desc'))),
})
// Input: { q: "stopcock", page: "3", limit: "50" }
// Output: { q: "stopcock", page: 3, limit: 50, sort: undefined }
type ValidationError = { path: string[]; message: string; value: unknown }
type ValidationResult<A> = { success: true; data: A } | { success: false; errors: ValidationError[] }
type Schema<A> = { validate: (input: unknown) => ValidationResult<A>; _def: SchemaDef }
type Infer<S> = S extends Schema<infer T> ? T : never
V.string(): Schema<string> & { min(n): ...; max(n): ...; regex(r): ...; email(): ...; url(): ... }
V.number(): Schema<number> & { min(n): ...; max(n): ...; int(): ...; positive(): ... }
V.boolean(): Schema<boolean>
V.literal<T>(value: T): Schema<T>
V.enum<T>(values: T[]): Schema<T[number]>

String and number schemas are chainable:

V.string().min(3).max(100).email()
V.number().int().min(0).max(100)
V.object<S>(shape: S): Schema<InferShape<S>> & { strict(): ... }
V.array<T>(element: Schema<T>): Schema<T[]> & { min(n): ...; max(n): ... }
V.tuple<T>(...elements: T): Schema<[...inferred]>
V.record<V>(key: Schema<string>, value: Schema<V>): Schema<Record<string, V>>
V.union<T>(...variants: T): Schema<T[number]>
V.optional<T>(inner: Schema<T>): Schema<T | undefined>
V.nullable<T>(inner: Schema<T>): Schema<T | null>

object validates shape keys. .strict() rejects unknown keys.

V.custom<T>(validate: (input: unknown) => boolean, message: string): Schema<T>
V.transform<A, B>(inner: Schema<A>, fn: (a: A) => B): Schema<B>

transform validates, then maps the value. The output type changes:

const Port = V.transform(
V.string(),
s => parseInt(s, 10),
)
// Schema<number>
compile<A>(schema: Schema<A>): (input: unknown) => ValidationResult<A>

compile JIT-compiles the schema into a specialized validation function. Useful when validating the same schema on every request:

const validateUser = compile(User)
const result = validateUser(body) // faster than User.validate(body)