close
Skip to content

feat(v4): add z.getDiscriminatedOption - #5947

Merged
colinhacks merged 9 commits into
colinhacks:mainfrom
dokson:feat/discriminated-union-options-map
Aug 19, 2026
Merged

colinhacks merged 9 commits into
colinhacks:mainfrom
dokson:feat/discriminated-union-options-map

Conversation

@dokson

@dokson dokson commented May 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a public, type-safe way to look up discriminatedUnion member schemas by their discriminator value. Refs #5086 (point: "Access individual types via discriminant value").

Today the only way to do this is via the internal _zod.propValues map plus a manual find over options, both of which require type assertions and reach into undocumented surfaces.

Changes

  • core: promote the internal discriminator → member map to _zod.optionsMap (lazy), typed as ReadonlyMap<Primitive, Options[number]>. The parse path and the existing invalid_union issue keep working unchanged — they now read from the same _zod.optionsMap. No runtime behavior changes.
  • classic: on ZodDiscriminatedUnion, add:
    • optionsMap — read-only map of all valid discriminator values to their option schemas.
    • get(value) — returns the precise member schema matching the literal value passed in, narrowed at the type level.

The type helpers ($DiscriminatorValue, $DiscriminatedOption) handle the tricky case where a single member declares multiple discriminator values via z.literal([...]), by inverting the inference (V extends OV rather than OV extends V).

Example

const fruit = z.object({ type: z.literal("fruit"), seeds: z.boolean() });
const veg   = z.object({ type: z.literal("vegetable"), leafy: z.boolean() });
const schema = z.discriminatedUnion("type", [fruit, veg]);

const f = schema.get("fruit");        // typed as `typeof fruit`
schema.get("unknown");                // ts-error: not a valid discriminator value

for (const [v, member] of schema.optionsMap) {
  // ...
}

This also unblocks the type-safe error-discrimination workaround discussed in #5086 in user-land:

const disc = (input as any)?.[schema.def.discriminator];
const member = schema.get(disc);
if (member) {
  const r = member.safeParse(input);
  if (!r.success) return { type: disc, errors: z.treeifyError(r.error) };
}

Test plan

  • pnpm --filter zod build (strict tsc clean)
  • pnpm vitest run discriminated-unions — 78/78 passing, type errors: 0
  • New tests cover: lookup + expectTypeOf narrowing, @ts-expect-error on invalid discriminator values, multi-value member via z.literal([...])

@pullfrog

pullfrog Bot commented May 4, 2026 •

Copy link
Copy Markdown
Contributor

TL;DR — Adds a public, type-safe way to look up ZodDiscriminatedUnion member schemas by discriminator value via a new get(value) method and a read-only optionsMap. Refs #5086.

Key changes

  • Expose optionsMap and get(value) on ZodDiscriminatedUnion — new public members return the matching option schema, precisely narrowed at the type level.
  • Promote the internal discriminator map to _zod.optionsMap — the parse path and invalid_union issue now read from this same lazy map; no runtime behavior change.
  • Local DiscriminatorValue / DiscriminatedOption type helpers — non-exported helpers handle a single member declaring multiple discriminator values via z.literal([...]) by inverting the inference direction.

Summary | 3 files | 1 commit | base: main ← feat/discriminated-union-options-map


Typed lookup by discriminator value

Before: Only way to look up a member was via the internal _zod.propValues plus a manual find over options, requiring type assertions.
After: schema.get(value) returns the matching member schema, narrowed to the exact option type; invalid literals are rejected at compile time.

The type helpers are shaped so that V extends OV (rather than OV extends V) — this is what lets a member declared with z.literal(["x", "y"]) match on either value while keeping the return type precise.

How is narrowing kept precise across multi-value members?

DiscriminatedOption iterates each option, infers its discriminator value type OV from the option's output, and keeps the option when the caller's V is assignable to OV. For a member with z.literal(["x","y"]), OV is "x" | "y", so get("x") and get("y") both resolve to that same member.

packages/zod/src/v4/classic/schemas.ts


Shared lazy map between parse path and public API

Before: $ZodDiscriminatedUnion built a local disc = util.cached(...) map used only internally by parse and the invalid_union issue.
After: The same map is attached lazily as inst._zod.optionsMap via util.defineLazy, typed as ReadonlyMap<util.Primitive, Options[number]>, and reused by both the parse path and the classic-layer getter.

On the classic side, get is installed per-instance via _installLazyMethods, and optionsMap is defined once on the prototype as a getter that proxies to _zod.optionsMap.

packages/zod/src/v4/core/schemas.ts · packages/zod/src/v4/classic/schemas.ts


Tests

Before: No coverage for public discriminator-value lookup.
After: Two new tests cover map contents, identity of returned members, expectTypeOf narrowing for both single- and multi-value members, and a @ts-expect-error on an unknown discriminator literal.

packages/zod/src/v4/classic/tests/discriminated-unions.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Solid approach — exposing the existing internal map plus a typed get() wrapper is the minimal, sensible way to address the "access individual types via discriminant value" ask from #5086. Tests pass and the propValues → optionsMap hoist is a clean refactor. Noting a few changes before merge, ordered by impact.

  1. Two new $-prefixed public type exports in classic/schemas.ts. The classic file has no other $-prefixed exports today (SafeExtendShape nearby is the established pattern), so $DiscriminatorValue / $DiscriminatedOption both introduce a new naming convention and expand the public API surface for helpers that are only consumed by a single interface. Inlining them into the get / optionsMap declarations, or at least dropping the $ prefix, would match the surrounding code.

  2. get is installed per-instance instead of on the prototype. Every other method on classic schemas (see ZodObject, ZodArray, ZodNumber, etc.) uses _installLazyMethods, which installs once per prototype. (inst as any).get = ... allocates a fresh closure for every discriminated union constructed. Minor, but it's the outlier in the file.

  3. JSDoc on get says "or undefined if no option matches", but the return type never includes undefined. Given V extends $DiscriminatorValue<Options, Disc> already constrains to valid values at the type level, the doc comment is misleading for well-typed callers — it only applies when the caller casts. Either drop the "or undefined" clause, or widen the return type to ... | undefined to match runtime behavior. The example in the PR description (if (member) { ... }) suggests users will hit this.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread packages/zod/src/v4/classic/schemas.ts Outdated
Comment on lines +1602 to +1618
export type $DiscriminatorValue<Options extends readonly core.SomeType[], Disc extends string> = {
[I in keyof Options]: Options[I] extends { _zod: { output: infer Out } }
? Out extends Record<Disc, infer V>
? V
: never
: never;
}[number];

export type $DiscriminatedOption<Options extends readonly core.SomeType[], Disc extends string, V> = {
[I in keyof Options]: Options[I] extends { _zod: { output: infer Out } }
? Out extends Record<Disc, infer OV>
? V extends OV
? Options[I]
: never
: never
: never;
}[number];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the only $-prefixed exported types in classic/schemas.ts (compare SafeExtendShape at L1365, same file). They're also only consumed by the get / optionsMap members below — so they're expanding the public surface with helpers nobody else needs. Consider inlining them into the interface, or at minimum dropping the $ prefix to match the local convention.

Comment thread packages/zod/src/v4/classic/schemas.ts Outdated
"~standard": ZodStandardSchemaWithJSON<this>;
_zod: core.$ZodDiscriminatedUnionInternals<Options, Disc>;
def: core.$ZodDiscriminatedUnionDef<Options, Disc>;
/** Returns the option schema matching the given discriminator value, or `undefined` if no option matches. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSDoc says "or undefined if no option matches" but the declared return type is $DiscriminatedOption<Options, Disc, V> — no | undefined. Because V extends $DiscriminatorValue<Options, Disc>, a well-typed caller never hits the undefined branch, but a caller who casts (as in the PR description's schema.get(disc as any) pattern) does. Either drop the "or undefined" phrasing, or reflect the runtime by making the return $DiscriminatedOption<Options, Disc, V> | undefined.

Comment thread packages/zod/src/v4/classic/schemas.ts Resolved
dokson added a commit to dokson/zod that referenced this pull request May 4, 2026
- Drop $-prefix on DiscriminatorValue/DiscriminatedOption helpers and
  make them file-local; matches the SafeExtendShape convention in this
  file (no $-prefixed exports here).
- Install get() on the prototype via _installLazyMethods (single
  closure per prototype) instead of allocating a fresh closure per
  schema instance. Define optionsMap as a prototype getter once.
- Drop the misleading "or undefined" clause from get()'s JSDoc — the
  return type is non-undefined when V is constrained to a valid
  discriminator value.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Prior feedback addressed cleanly. One remaining consistency concern: optionsMap is installed via a hand-rolled Object.defineProperty(proto, ...) + getOwnPropertyDescriptor guard, which doesn't appear anywhere else in classic/schemas.ts. The analogous property is shape on ZodObject, which uses util.defineLazy(inst, "shape", () => def.shape) at line 1460. optionsMap would match that pattern as a one-liner.

Reviewed the following changes:

  • Renamed $DiscriminatorValue / $DiscriminatedOption to un-prefixed, non-exported helpers.
  • Moved get onto the prototype via _installLazyMethods instead of per-instance assignment.
  • Updated get JSDoc to drop the misleading "or undefined" clause, matching the non-nullable return type.
  • Added a test covering single-member with multi-value discriminator via z.literal([...]).

Prior review feedback:

  • $-prefixed public type exports — addressed (renamed and unexported)
  • get installed per-instance instead of on the prototype — addressed (moved to _installLazyMethods)
  • JSDoc mentions undefined that the return type doesn't allow — addressed

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using Claude Opus | 𝕏

Comment thread packages/zod/src/v4/classic/schemas.ts Resolved
Adds a public, type-safe way to access discriminated union members by
their discriminator value, replacing the need to reach into
_zod.propValues or to iterate options manually.

- core: promote the internal discriminator->member map to
  _zod.optionsMap (lazy), typed as ReadonlyMap<Primitive, Options[number]>.
- classic: add `optionsMap` getter and `get(value)` method on
  ZodDiscriminatedUnion. `get(value)` returns the precise member schema
  matching the literal value, including the case where a single member
  declares multiple discriminator values via z.literal([...]).
- classic: `get` is installed once per prototype via _installLazyMethods;
  `optionsMap` is set up via util.defineLazy to match the existing
  pattern used for ZodObject's `shape`.

Refs colinhacks#5086.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues. Reviewed the following changes:

  • Switched optionsMap installation on the classic wrapper from a hand-rolled Object.defineProperty(proto, ...) to util.defineLazy(inst, "optionsMap", ...), matching the ZodObject.shape pattern.

Prior review feedback:

  • optionsMap installed via bespoke Object.defineProperty + getOwnPropertyDescriptor guard — addressed (now util.defineLazy)

Pullfrog  | View workflow run | Using Claude Opus | 𝕏

# Conflicts:
#	packages/zod/src/v4/classic/tests/discriminated-unions.test.ts
Three defects surfaced once this landed on top of colinhacks#6318/colinhacks#6415, which moved
schema members to a per-constructor prototype.

- `installLazyMethods` takes a factory, not an object literal, so every
  `z.discriminatedUnion()` threw `TypeError: methods is not a function` at
  construction. The merge with main was textually clean, so nothing warned.
- The sentinel must be a key the group itself defines. Passing the class
  name meant `claim()` never short-circuited and the prototype accessor was
  redefined on every construction, which drops the prototype into dictionary
  mode.
- Routing the parse path through `_zod.optionsMap` regressed duplicate
  discriminators: `defineLazy` latches on a throwing getter, so the second
  parse reported `Cannot read properties of undefined` instead of
  `Duplicate discriminator value`. The parse path now keeps reading the
  `util.cached` box and `optionsMap` delegates to it, leaving core purely
  additive.

Also notes on `optionsMap` that it is the live parse-path map, and adds a
discriminated union to the own-property footprint guard.
Drops the core change entirely. The map is derived per instance from each
option's propValues and installed with installLazyProps, so it lives on the
prototype and materializes only when read. Removes get(value) and the
DiscriminatedOption mapped type with it.
# Conflicts:
#	packages/zod/src/v4/core/schemas.ts
Replaces the classic-only optionsMap member with a top-level util shared by
classic and mini. The derived map is cached in _zod.bag, which already exists
on every schema, so no new internals property is added and nothing is computed
until the util is called.
Verb-first, matching the other top-level utils, and it does not read as
though the value passed were the discriminator itself — the discriminator
is the key name.
@colinhacks colinhacks changed the title feat(v4): expose optionsMap and get(value) on ZodDiscriminatedUnion feat(v4): add z.getDiscriminatedOption Aug 19, 2026
@colinhacks
colinhacks merged commit 7785fc8 into colinhacks:main Aug 19, 2026
7 checks passed
@dokson
dokson deleted the feat/discriminated-union-options-map branch October 2, 2026 16:20

This branch was successfully deployed

1 active deployment
Preview – zod-v4 — 1d743434 Deployed Aug 19, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants