Tag-routed polymorphic schemas with `v.discriminatedUnion`
Real-world data is often one of N shapes, distinguished by a tag field. Notifications come as email, sms, or push. Events have a type. AI tool calls have a name. The shapes share a discriminator but differ everywhere else.
v.union can express this: it tries every object branch in order until one passes. But it has to try them one by one, and when none passes it can only report the branch that came closest — a guess, not the branch the tag names.
v.discriminatedUnion solves this. You name the discriminator, every branch carries a v.literal(…) on that field, and validation routes by reading the field’s value at runtime.
The pattern
Section titled “The pattern”import { v, validate, type Infer } from "@warlock.js/seal";
const email = v.object({ type: v.literal("email"), to: v.string().email(), subject: v.string(),});
const sms = v.object({ type: v.literal("sms"), to: v.string(), message: v.string(),});
const push = v.object({ type: v.literal("push"), deviceId: v.string(), title: v.string(), body: v.string(),});
const notification = v.discriminatedUnion("type", [email, sms, push]);
type Notification = Infer<typeof notification>;// { type: "email"; to: string; subject: string }// | { type: "sms"; to: string; message: string }// | { type: "push"; deviceId: string; title: string; body: string }What happens at validate-time
Section titled “What happens at validate-time”Three things, in order:
- Read
data[discriminator]— for the example above,data.type. - Look up the branch in the key→branch map built at construction. The map is
{ "email" → emailSchema, "sms" → smsSchema, "push" → pushSchema }. - Delegate to the matched branch. Errors (if any) come from that branch only.
If the discriminator’s value isn’t in the map, validation fails with one clear error: "Field 'type' must be one of: email, sms, push". If the input isn’t a plain object, validation fails with "Expected object with discriminator field 'type'".
Compared to plain v.union
Section titled “Compared to plain v.union”// Without discriminator routing: each branch is tried in turnconst naive = v.union([email, sms]);await validate(naive, { type: "sms", to: "555-1234" });// → ERROR from the closest branch, ranked by mismatched literals,// then error count, then depth. Here sms wins (its `type` literal matches),// so the error is about its missing `message`.
// With discriminator routingconst tagged = v.discriminatedUnion("type", [email, sms]);await validate(tagged, { type: "sms", to: "555-1234" });// → ERROR from the sms branch, chosen by reading `type` once.Both accept a valid payload for any branch. v.discriminatedUnion gets there without validating the other branches first, and its error always comes from the branch the tag names.
Construction-time validation
Section titled “Construction-time validation”The discriminator contract is checked when you build the validator, not when you run it. Three failure modes throw at schema-build time:
// 1. Missing discriminator field on some branchv.discriminatedUnion("type", [ v.object({ type: v.literal("a"), x: v.string() }), v.object({ y: v.string() }), // ❌ no "type" field]);// Throws: [Seal] discriminatedUnion: branch missing discriminator field "type"
// 2. Non-literal discriminatorv.discriminatedUnion("type", [ v.object({ type: v.string(), x: v.string() }), // ❌ not a v.literal]);// Throws: [Seal] discriminatedUnion: discriminator "type" must be v.literal(...) on every branch
// 3. Duplicate discriminator valuesv.discriminatedUnion("type", [ v.object({ type: v.literal("a"), x: v.string() }), v.object({ type: v.literal("a"), y: v.string() }), // ❌ "a" used twice]);// Throws: [Seal] discriminatedUnion: duplicate discriminator value "a"Failing eagerly means tests catch misconfigurations at definition time. You don’t ship a schema that silently routes payloads wrong.
Multi-literal discriminators
Section titled “Multi-literal discriminators”A single branch can accept multiple discriminator values via v.literal("a", "b"):
const status = v.discriminatedUnion("status", [ v.object({ status: v.literal("draft", "scheduled"), publishAt: v.date().optional(), }), v.object({ status: v.literal("published"), publishedAt: v.date(), publishedBy: v.string(), }), v.object({ status: v.literal("archived"), archivedAt: v.date(), }),]);Both "draft" and "scheduled" route to the first branch.
Numeric and boolean discriminators
Section titled “Numeric and boolean discriminators”v.literal accepts string | number | boolean, so discriminators can be any of those:
v.discriminatedUnion("version", [ v.object({ version: v.literal(1), legacy: v.string() }), v.object({ version: v.literal(2), modern: v.string() }),]);
v.discriminatedUnion("isAdmin", [ v.object({ isAdmin: v.literal(true), adminScopes: v.array(v.string()) }), v.object({ isAdmin: v.literal(false), userScopes: v.array(v.string()) }),]);The lookup map handles all three primitive types identically.
AI tool-calling use case
Section titled “AI tool-calling use case”The pattern shines for AI tool calls — every tool has a name (the discriminator) and specific args (the branch body):
const searchTool = v.object({ name: v.literal("search"), args: v.object({ query: v.string(), limit: v.int().min(1).max(50) }),});
const fetchTool = v.object({ name: v.literal("fetch"), args: v.object({ url: v.string().url() }),});
const summarizeTool = v.object({ name: v.literal("summarize"), args: v.object({ text: v.string().min(10), maxWords: v.int().optional() }),});
const toolCall = v.discriminatedUnion("name", [searchTool, fetchTool, summarizeTool]);Pair with Infer<typeof toolCall> and TS narrows the args type inside if (call.name === "search") blocks. No casting, no any, no runtime type assertions.
JSON Schema output
Section titled “JSON Schema output”v.discriminatedUnion(...).toJsonSchema() emits oneOf with each branch’s own JSON Schema. Each branch’s type discriminator becomes properties.type.const, so JSON-Schema-aware tooling (OpenAPI generators, AJV with discriminator support) can route as well:
notification.toJsonSchema("openai-strict");// → {// oneOf: [// { type: "object", properties: { type: { const: "email" }, to: ..., subject: ... }, required: [...], additionalProperties: false },// { type: "object", properties: { type: { const: "sms" }, to: ..., message: ... }, required: [...], additionalProperties: false },// { type: "object", properties: { type: { const: "push" }, deviceId: ..., title: ..., body: ... }, required: [...], additionalProperties: false },// ],// }OpenAI strict mode accepts this shape — every branch’s required fields are listed; optional fields are expressed as nullable types per the strict contract.
When to reach for it (and when not)
Section titled “When to reach for it (and when not)”Use v.discriminatedUnion when:
- Multiple branches share a common tag field
- The tag is a literal value (
v.literal(...)) per branch - You want precise errors from the matched branch
Stay on v.union when:
- The branches are different types (string vs number vs boolean) —
matchesTypehandles those well - There’s no natural discriminator (object branches are fine: each is tried in order)
Use a single v.object with v.literal(...one-of...) when:
- The non-discriminator fields are identical across branches — discriminator is just a tag, not a router
Related
Section titled “Related”- Essentials → Structural shapes —
v.discriminatedUnionin context with the other structural validators - Guides → Generate JSON Schema —
oneOfoutput for OpenAI strict mode