Repository navigation
feat(v4): add z.getDiscriminatedOption - #5947
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 | 𝕏
# 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.


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([...])