close
Skip to content

Commit 7785fc8

Browse files
authored
feat(v4): add z.getDiscriminatedOption (#5947)
Adds `z.getDiscriminatedOption(union, value)`, which returns the option of a discriminated union whose discriminator claims `value`. The result is narrowed to the single matching option, including when one option declares several values via `z.literal([...])`. Defined in core and re-exported from both classic and mini, so both get the identical function. The derived map is cached in the existing `_zod.bag`, so no new internals property is added and nothing is computed until the util is first called. The parse path is untouched. Closes the by-value lookup half of #5086. Co-authored-by: Alessandro Colace <ale.colace@gmail.com>
1 parent 0135c85 commit 7785fc8

5 files changed

Lines changed: 106 additions & 0 deletions

File tree

‎packages/zod/src/v4/classic/external.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export {
2727
compile,
2828
ZodCompileAsyncError,
2929
ZodCompileUnsupportedError,
30+
getDiscriminatedOption,
3031
} from "../core/index.js";
3132
export { toJSONSchema } from "../core/json-schema-processors.js";
3233
export { fromJSONSchema } from "./from-json-schema.js";

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,57 @@ test("encode with codec discriminator", () => {
740740
expect(encoded).toEqual({ type: 1, value: "hello" });
741741
});
742742

743+
test("getDiscriminatedOption", () => {
744+
const fruit = z.object({ type: z.literal("fruit"), seeds: z.boolean() });
745+
const veg = z.object({ type: z.literal("vegetable"), leafy: z.boolean() });
746+
const schema = z.discriminatedUnion("type", [fruit, veg]);
747+
748+
expect(z.getDiscriminatedOption(schema, "fruit")).toBe(fruit);
749+
expect(z.getDiscriminatedOption(schema, "vegetable")).toBe(veg);
750+
751+
// The result is narrowed to the one member, not the union of all of them.
752+
expectTypeOf(z.getDiscriminatedOption(schema, "fruit")).toEqualTypeOf<typeof fruit>();
753+
expectTypeOf(z.getDiscriminatedOption(schema, "vegetable")).toEqualTypeOf<typeof veg>();
754+
// @ts-expect-error — "unknown" is not a declared discriminator value
755+
z.getDiscriminatedOption(schema, "unknown");
756+
});
757+
758+
test("getDiscriminatedOption — multi-value members and non-string discriminators", () => {
759+
const a = z.object({ type: z.literal(["x", "y"]), payload: z.string() });
760+
const b = z.object({ type: z.literal("z"), payload: z.number() });
761+
const multi = z.discriminatedUnion("type", [a, b]);
762+
expect(z.getDiscriminatedOption(multi, "x")).toBe(a);
763+
expect(z.getDiscriminatedOption(multi, "y")).toBe(a);
764+
expect(z.getDiscriminatedOption(multi, "z")).toBe(b);
765+
766+
const num = z.object({ type: z.literal(1) });
767+
const bool = z.object({ type: z.literal(true) });
768+
const nul = z.object({ type: z.null() });
769+
const mixed = z.discriminatedUnion("type", [num, bool, nul]);
770+
expect(z.getDiscriminatedOption(mixed, 1)).toBe(num);
771+
expect(z.getDiscriminatedOption(mixed, true)).toBe(bool);
772+
expect(z.getDiscriminatedOption(mixed, null)).toBe(nul);
773+
774+
// An omittable discriminator claims undefined, so it resolves like any other value.
775+
const opt = z.object({ type: z.literal("a").optional(), a: z.string() });
776+
const req = z.object({ type: z.literal("b"), b: z.string() });
777+
expect(z.getDiscriminatedOption(z.discriminatedUnion("type", [opt, req]), undefined)).toBe(opt);
778+
});
779+
780+
test("getDiscriminatedOption caches in the bag and costs nothing until called", () => {
781+
const schema = z.discriminatedUnion("type", [z.object({ type: z.literal("a") }), z.object({ type: z.literal("b") })]);
782+
783+
expect(schema._zod.bag.optionsMap).toBeUndefined();
784+
expect(z.getDiscriminatedOption(schema, "a")).toBe(schema.options[0]);
785+
const map = schema._zod.bag.optionsMap;
786+
expect(map).toBeInstanceOf(Map);
787+
expect(z.getDiscriminatedOption(schema, "b")).toBe(schema.options[1]);
788+
expect(schema._zod.bag.optionsMap).toBe(map);
789+
790+
// A clone recomputes rather than inheriting the cache.
791+
expect(schema.clone()._zod.bag.optionsMap).toBeUndefined();
792+
});
793+
743794
test.each(["__proto__", "constructor", "toString", "hasOwnProperty", "valueOf"])(
744795
"Object.prototype discriminator name: %s",
745796
(key) => {

‎packages/zod/src/v4/core/schemas.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2482,6 +2482,49 @@ export interface $ZodDiscriminatedUnionInternals<
24822482
> extends $ZodUnionInternals<Options> {
24832483
def: $ZodDiscriminatedUnionDef<Options, Disc>;
24842484
propValues: util.PropValues;
2485+
bag: util.LoosePartial<{
2486+
optionsMap: Map<util.Primitive, $ZodType>;
2487+
}>;
2488+
}
2489+
2490+
/** The discriminator values a member of `Options` can declare. An omittable discriminator contributes `undefined`, matching what `propValues` claims for it. */
2491+
export type $DiscriminatorValue<Options extends readonly SomeType[], Disc extends string> = {
2492+
[I in keyof Options]: Options[I] extends { _zod: { output: infer Out } }
2493+
? Disc extends keyof Out
2494+
? Out[Disc]
2495+
: never
2496+
: never;
2497+
}[number];
2498+
2499+
/** The member of `Options` that declares `V`. */
2500+
export type $DiscriminatedOption<Options extends readonly SomeType[], Disc extends string, V> = {
2501+
[I in keyof Options]: Options[I] extends { _zod: { output: infer Out } }
2502+
? Disc extends keyof Out
2503+
? V extends Out[Disc]
2504+
? Options[I]
2505+
: never
2506+
: never
2507+
: never;
2508+
}[number];
2509+
2510+
/** Returns the option of `union` whose discriminator claims `value`. */
2511+
export function getDiscriminatedOption<
2512+
Options extends readonly SomeType[],
2513+
Disc extends string,
2514+
const V extends $DiscriminatorValue<Options, Disc>,
2515+
>(union: $ZodDiscriminatedUnion<Options, Disc>, value: V): $DiscriminatedOption<Options, Disc, V> {
2516+
const internals = union._zod;
2517+
let map = internals.bag.optionsMap;
2518+
if (!map) {
2519+
map = new Map();
2520+
const { options, discriminator } = internals.def;
2521+
for (const option of options as unknown as readonly $ZodType[]) {
2522+
// First declaration wins, matching the order the parse path resolves a duplicate in.
2523+
for (const v of option._zod.propValues?.[discriminator] ?? []) if (!map.has(v)) map.set(v, option);
2524+
}
2525+
internals.bag.optionsMap = map;
2526+
}
2527+
return map.get(value as util.Primitive) as any;
24852528
}
24862529

24872530
export interface $ZodDiscriminatedUnion<

‎packages/zod/src/v4/mini/external.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export {
2626
compile,
2727
ZodCompileAsyncError,
2828
ZodCompileUnsupportedError,
29+
getDiscriminatedOption,
2930
} from "../core/index.js";
3031
export { toJSONSchema } from "../core/json-schema-processors.js";
3132

‎packages/zod/src/v4/mini/tests/index.test.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,3 +1032,13 @@ test("type narrowing works with type property", () => {
10321032
expect(arraySchema.def.element).toBeDefined();
10331033
}
10341034
});
1035+
1036+
test("getDiscriminatedOption", () => {
1037+
const a = z.object({ type: z.literal("a"), x: z.string() });
1038+
const b = z.object({ type: z.literal("b"), y: z.number() });
1039+
const schema = z.discriminatedUnion("type", [a, b]);
1040+
1041+
expect(z.getDiscriminatedOption(schema, "a")).toBe(a);
1042+
expect(z.getDiscriminatedOption(schema, "b")).toBe(b);
1043+
expectTypeOf(z.getDiscriminatedOption(schema, "a")).toEqualTypeOf<typeof a>();
1044+
});

0 commit comments

Comments
 (0)