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"),});- Plain objects only. Arrays, dates, maps, sets, custom-prototype objects and class instances fail, including with
.allowUnknown()or.stripUnknown(). Objects with a null prototype are accepted. Convert an instance to an explicit plain DTO before validation; there is no permissive opt-in. - 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 rejected by default — validation fails with “The schema contains unknown keys: …”. Toggle behavior:
.allowUnknown()— forward extras as-is..stripUnknown()— drop extras and return the clean object instead of failing..allow("trackingId", "_meta")— whitelist specific extras.
Rejecting is the right default for inbound payloads. For an outbound DTO — a record that legitimately carries internal fields you don’t want to expose — reach for .stripUnknown():
const publicArticle = v .object({ id: v.string(), title: v.string(), }) .stripUnknown();
const { isValid, data } = await v.validate(publicArticle, record);// record had version / authorId / status; data has only id + titleComposing 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 optional (and optional in the inferred type)userBase.requiredFields("email", "name"); // force-required specific keyspartial() with no arguments makes every key optional, and pick(), without() and partial() all keep the inferred type of the result.
Server-set fields — .internal()
Section titled “Server-set fields — .internal()”Mark a field .internal() when the server fills it and a client must never send it. The field validates, casts and infers like any other; withoutInternal() derives the request-facing schema without it:
const post = v.object({ title: v.string().required(), authorId: v.int().required().internal(),});
post.internalKeys(); // ["authorId"]const request = post.withoutInternal(); // { title: string }These 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 but values share a schema. Supply a key validator first to constrain the present keys:
const counts = v.record(v.literal("draft", "published"), v.int());await v.validate(counts, { draft: 2 }); // valid; published may be absentawait v.validate(counts, { other: 2 }); // invalid key, input: otherawait v.validate(counts, { draft: "two" }); // invalid valueBoth forms infer the value validator’s distinct input and output types. String key validators infer Record<string, Value>; literal keys infer Partial<Record<"draft" | "published", Value>>, because absent keys are not required. Use v.object when every named key must exist. Key validators check original string property names; their transforms do not rename keys. JSON Schema includes propertyNames for the key schema. Malformed validators throw TypeError at definition time. The existing v.record(valueValidator, "Custom object error") form remains supported; the keyed form accepts that message as a third argument.
Upgrading from 5.33
Section titled “Upgrading from 5.33”BREAKING: v.object() now refuses non-plain objects. Use v.array(itemSchema) for arrays, v.instanceof(YourClass) for instance identity checks, or validate an explicitly constructed DTO such as { name: instance.name }. Unknown-key options do not relax the input 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]).
Pass the validators as a literal list so each position keeps its own type, for input and validated output alike. A list typed BaseValidator[] (built at runtime) has no fixed positions, so its output is an array rather than a tuple.
v.union — one of N validators (untagged)
Section titled “v.union — one of N validators (untagged)”v.union([v.string(), v.int()]); // type: string | numberEvery branch whose type matches the value (matchesType()) is tried, in order, and the first that passes wins; the union returns that branch’s validated output. firstErrorOnly limits the errors reported, never the branches tried, so a union of several object shapes accepts a value matching any of them:
const owner = v.object({ kind: v.literal("owner"), id: v.literal("__owner__") });const member = v.object({ kind: v.literal("member"), id: v.string() });
await validate(v.union([owner, member]), { kind: "member", id: "x" });// → valid, data: { kind: "member", id: "x" }When no branch passes, the union reports the closest failed branch, picked by: fewest mismatched v.literal(...) fields, then fewest errors, then the deepest error path, then declaration order. Its errors keep their own paths (actor.id, not actor) and have the type "union" — the first one by default, all of them with firstErrorOnly: false. A custom errorMessage (v.union([...], "Unknown actor")), or a value no branch can hold by type, gives a single error at the union’s path.
For object shapes that share a tag field, v.discriminatedUnion is still the better fit: it routes straight to one branch instead of trying each, and narrows exactly in TypeScript.
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 branch the tag names, not from the closest guess.
- O(1) routing instead of trying each branch.
- 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.