Structural shapes
Primitives describe single values. Structural validators describe how values nest. The five structural factories compose — pass leaf primitives or other structural validators inside, infer the full nested type with Infer<typeof schema>.
v.object — fixed-key records
Section titled “v.object — fixed-key records”v.object({ email: v.string().email(), age: v.int().min(13).optional(), role: v.literal("admin", "user", "guest"),});- Required by default.
.optional()to opt out. The inferred type marks optional keys with?. - Cross-field rules live here.
.sameAs("password"),.requiredIf("role", "admin")resolve siblings against the parentv.object. Without a parent, sibling resolution silently passes. - Unknown keys are dropped silently by default. Toggle behavior:
.allowUnknown()— forward extras as-is..stripUnknown()— explicit drop (the default behavior, called out)..allow("trackingId", "_meta")— whitelist specific extras.
Composing objects
Section titled “Composing objects”v.object schemas can be reshaped and combined with first-class methods:
const userBase = v.object({ email: v.string().email(), name: v.string(), passwordHash: v.string(),});
// Add fieldsuserBase.extend({ role: v.literal("admin", "user") });
// Merge with another schemaconst auditFields = v.object({ createdAt: v.date(), updatedAt: v.date(),});userBase.merge(auditFields);
// Subset / supersetuserBase.pick("email", "name"); // only those two keysuserBase.without("passwordHash"); // drop oneuserBase.partial("email"); // mark specific keys optionaluserBase.requiredFields("email", "name"); // force-required specific keysThese return new validators — the source is untouched.
v.array — homogeneous lists
Section titled “v.array — homogeneous lists”v.array(v.string()) // type: string[]v.array(userSchema) // type: User[]v.array(v.array(v.int())) // type: number[][] — nests naturallyThe inner validator runs against each element. Failure on any element fails the array.
Length and uniqueness constraints:
v.array(v.string()).minLength(1).maxLength(10)v.array(v.string()).length(5) // exactly 5 itemsv.array(v.string()).unique() // no duplicatesv.record — homogeneous values, dynamic keys
Section titled “v.record — homogeneous values, dynamic keys”v.record(v.int()) // type: Record<string, number>v.record(v.object({ count: v.int() })) // type: Record<string, { count: number }>v.record() // type: Record<string, any>Reach for v.record when keys are dynamic (user-supplied, dictionary-style) but values share a schema. If keys are also constrained (e.g. only "draft" | "published"), use v.object with literal keys instead — the constraint lives in the type.
v.tuple — positional types
Section titled “v.tuple — positional types”v.tuple([v.string(), v.int(), v.boolean()]) // type: [string, number, boolean]v.tuple([v.literal("ok"), v.string()]) // type: ["ok", string]Each position has its own validator. The array length must match the tuple length. Pair with v.literal at position 0 for result-tuple patterns (["ok", data] vs ["error", message]).
v.union — one of N validators (untagged)
Section titled “v.union — one of N validators (untagged)”v.union([v.string(), v.int()]) // type: string | numberThe first type-matching branch wins, picked via each branch’s matchesType(). Use for unions of scalar types where matching against the JS type is enough to disambiguate.
For object-vs-object unions, reach for v.discriminatedUnion instead — matchesType can’t distinguish two object branches, and you’ll get errors from the wrong one.
v.discriminatedUnion — tagged unions (the right call for objects)
Section titled “v.discriminatedUnion — tagged unions (the right call for objects)”const email = v.object({ type: v.literal("email"), email: v.string().email() });const sms = v.object({ type: v.literal("sms"), phone: v.string() });const push = v.object({ type: v.literal("push"), deviceId: v.string() });
const notification = v.discriminatedUnion("type", [email, sms, push]);
type Notif = Infer<typeof notification>;// { type: "email"; email: string }// | { type: "sms"; phone: string }// | { type: "push"; deviceId: string }Routes payloads by reading the discriminator field, looking it up in a key→branch map built at construction time, and delegating to the matching branch only.
Benefits over plain v.union:
- Precise errors. Failures come from the matched branch, not from every branch.
- O(1) routing instead of trial-and-error.
- Exact TypeScript narrowing inside
if (x.type === "email")blocks. - Cleaner JSON Schema —
oneOfwith literal discriminators; OpenAI strict mode accepts it.
Construction-time validation throws on:
- Missing discriminator field on any branch.
- Non-literal discriminator (must be
v.literal(...)). - Duplicate discriminator values across branches.
Misconfigurations surface at schema-build time, not at runtime.
v.lazy — recursive and forward references
Section titled “v.lazy — recursive and forward references”Some shapes describe themselves — a category has sub-categories, a comment has replies. Referencing the schema from inside its own definition hits a JavaScript evaluation-order problem: the inner reference fires before the const binding resolves.
type Category = { name: string; children: Category[] };
const categorySchema: ObjectValidator<{ name: ReturnType<typeof v.string>; children: ReturnType<typeof v.array>;}> = v.object({ name: v.string(), children: v.array(v.lazy(() => categorySchema)),});
type T = Infer<typeof categorySchema>;// { name: string; children: T[] } ← recursive typeThree pieces make this work:
- The thunk
() => categorySchema— evaluated lazily, by which time the binding resolves. - The recursive type alias
Category— TypeScript can’t infer recursion from the validator alone. - The explicit annotation
ObjectValidator<...>— without it, TS won’t accept the circular reference. Same pattern as Zod’sz.ZodType<Category>.
The thunk fires once per validator instance (memoised) — calling validate() 10,000 times invokes the thunk once.
JSON Schema caveat. Simple-resolve in v1 — recursive shapes will infinite-loop in toJsonSchema(). If you need JSON Schema for a recursive shape, generate it manually with $defs + $ref until proper $ref support lands.
The recursive schemas recipe covers mutual recursion, forward references, and the depth/cycle gotchas.
Quick map
Section titled “Quick map”| Want | Reach for |
|---|---|
| Fixed-shape record | v.object({...}) |
| Dynamic keys, same value shape | v.record(valueSchema) |
| List of items | v.array(itemSchema) |
| Position-typed array | v.tuple([a, b, c]) |
| One of N scalar types | v.union([...]) |
| One of N object shapes with a tag field | v.discriminatedUnion(key, [...]) |
| Self-referencing or forward reference | v.lazy(() => schema) |
| One of N constants | v.literal(...values) (not structural — a primitive) |
Related
Section titled “Related”- Primitives — the leaf validators that go inside structural shapes.
- Modifiers —
.optional,.default,.catch, the cross-cutting chain methods. - Recipe → Polymorphic data — tag-routed unions in depth.
- Recipe → Recursive schemas —
v.lazypatterns.