validate
bun add @stopcock/validateDefine 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[]}vs. zod
Section titled “vs. zod”// zodimport { 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 compilationimport { 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>// zodapp.post('/users', (req) => { const result = User.safeParse(req.body) if (!result.success) return new Response( JSON.stringify(result.error.format()), { status: 400 } ) createUser(result.data)})
// stopcockimport { json } from '@stopcock/serve'const validateUser = compile(User)
export const POST = (ctx) => { const result = validateUser(ctx.body) if (!result.success) return json(result.errors, 400) createUser(result.data)}Real-world patterns
Section titled “Real-world patterns”Environment variable validation
Section titled “Environment variable validation”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, ... }Nested form validation with error display
Section titled “Nested form validation with error display”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}Transform pipeline
Section titled “Transform pipeline”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 : neverPrimitives
Section titled “Primitives”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)Composites
Section titled “Composites”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.
Advanced
Section titled “Advanced”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>Compilation
Section titled “Compilation”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)