feat(v4): expose optionsMap and get(value) on ZodDiscriminatedUnion#5947
feat(v4): expose optionsMap and get(value) on ZodDiscriminatedUnion#5947dokson wants to merge 1 commit into
Conversation
|
TL;DR — Adds a public, type-safe way to look up Key changes
Summary | 3 files | 1 commit | base: Typed lookup by discriminator value
The type helpers are shaped so that
Shared lazy map between parse path and public API
On the classic side,
Tests
|
There was a problem hiding this comment.
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.
-
Two new
$-prefixed public type exports inclassic/schemas.ts. The classic file has no other$-prefixed exports today (SafeExtendShapenearby is the established pattern), so$DiscriminatorValue/$DiscriminatedOptionboth introduce a new naming convention and expand the public API surface for helpers that are only consumed by a single interface. Inlining them into theget/optionsMapdeclarations, or at least dropping the$prefix, would match the surrounding code. -
getis installed per-instance instead of on the prototype. Every other method on classic schemas (seeZodObject,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. -
JSDoc on
getsays "orundefinedif no option matches", but the return type never includesundefined. GivenV 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 "orundefined" clause, or widen the return type to... | undefinedto match runtime behavior. The example in the PR description (if (member) { ... }) suggests users will hit this.
Claude Opus | 𝕏
| 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]; |
There was a problem hiding this comment.
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.
| "~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. */ |
There was a problem hiding this comment.
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.
- 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.
There was a problem hiding this comment.
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/$DiscriminatedOptionto un-prefixed, non-exported helpers. - Moved
getonto the prototype via_installLazyMethodsinstead of per-instance assignment. - Updated
getJSDoc to drop the misleading "orundefined" 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) -
getinstalled per-instance instead of on the prototype — addressed (moved to_installLazyMethods) - JSDoc mentions
undefinedthat the return type doesn't allow — addressed
Claude Opus | 𝕏
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.
ae5024a to
77a33fc
Compare
There was a problem hiding this comment.
No new issues. Reviewed the following changes:
- Switched
optionsMapinstallation on the classic wrapper from a hand-rolledObject.defineProperty(proto, ...)toutil.defineLazy(inst, "optionsMap", ...), matching theZodObject.shapepattern.
Prior review feedback:
-
optionsMapinstalled via bespokeObject.defineProperty+getOwnPropertyDescriptorguard — addressed (nowutil.defineLazy)
Claude Opus | 𝕏

Summary
Adds a public, type-safe way to look up
discriminatedUnionmember 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.propValuesmap plus a manualfindoveroptions, both of which require type assertions and reach into undocumented surfaces.Changes
discriminator → membermap to_zod.optionsMap(lazy), typed asReadonlyMap<Primitive, Options[number]>. The parse path and the existinginvalid_unionissue keep working unchanged — they now read from the same_zod.optionsMap. No runtime behavior changes.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 viaz.literal([...]), by inverting the inference (V extends OVrather thanOV extends V).Example
This also unblocks the type-safe error-discrimination workaround discussed in #5086 in user-land:
Test plan
pnpm --filter zod build(strict tsc clean)pnpm vitest run discriminated-unions— 78/78 passing, type errors: 0expectTypeOfnarrowing,@ts-expect-erroron invalid discriminator values, multi-value member viaz.literal([...])