Skip to content

feat(v4): expose optionsMap and get(value) on ZodDiscriminatedUnion#5947

Open
dokson wants to merge 1 commit into
colinhacks:mainfrom
dokson:feat/discriminated-union-options-map
Open

feat(v4): expose optionsMap and get(value) on ZodDiscriminatedUnion#5947
dokson wants to merge 1 commit into
colinhacks:mainfrom
dokson:feat/discriminated-union-options-map

Conversation

@dokson
Copy link
Copy Markdown
Contributor

@dokson dokson commented May 4, 2026

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
Copy link
Copy Markdown
Contributor

pullfrog Bot commented May 4, 2026

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: mainfeat/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𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

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 Outdated
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.
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

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 Outdated
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.
Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

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𝕏

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.

1 participant