close
Skip to content

zod4: better usable discriminatedUnions #5086

Closed

Description

@akomm

Since the introduction, the discriminatedUnion (further called union) its very awkward to use and usually involves a lot of dumb mapping-code where none should be needed in order to satisfy the type-checking and not bypass it with type asserts (foo as xyz). And also frequently the need to simply work around the union and simply use and map the types directly.

treeifyError

I use safeParse with once format, now treeifyError to propagate type-safe errors to the UI. It removes a lot of blind-spots, compared to just using unstructured generic error result types. The issue is that the result type on treeifyError is useless on a parse result from a union, because the error types are not discriminated. This example illustrates what I mean: TS Playground

In order to make the error result usable, I have to:

  1. Pre-parse the unknown input in order to extract just the type.
  2. Use the type pre-parsed from the input to branch (if-else or switch) through possible types
  3. Bypass union, instead use the types that are contained within the union based on the previously pre-parsed and matched type
  4. Call on the specific type from the union to safeParse and then result something like {type, error: z.treeifyError(parse.error)}

As you can see, for this instance I had basically to entirely work around the union.

Access individual types via discriminant value (enum-like)

We can access the union types as a typed tuple via union.options. But there is no way to access it by the discriminant value example

The union has just one angle to be access from, everything else becomes messy. Often times you need map some or all or none and just get the errors nicely.

Activity

  1. dosubot commented on Aug 11, 2025

    @dosubot

    You're right—Zod 4's discriminatedUnion still requires a fair bit of manual mapping for ergonomic type-safe handling, especially around error reporting and accessing schemas by discriminant value. The .options property exposes the tuple of schemas, but there's no public API to map from a discriminant value directly to its schema. Internally, Zod maintains a map for this, but it's not exposed for general use (source).

    For error handling, treeifyError and format don't discriminate errors by union member—they aggregate errors, so you can't easily branch on the discriminant in your UI (source). A recent update (Zod 4.0.10) does add a discriminator property to invalid_union errors, which helps a bit with debugging (source), but it doesn't solve the ergonomic mapping problem.

    There are some community workarounds:

    • Use a utility like orFallback to handle unknown discriminants and reduce boilerplate (discussion).
    • Use preprocess to map unknown discriminants to a fallback type (discussion).
    • For type narrowing after parsing, use TypeScript's Extract utility (example).
    • For introspection, you can access the internal _zod.propValues map on a discriminated union instance to see all possible discriminant values, but this is not a documented public API and may change (test example).

    There's ongoing discussion about making error formatting and union member access more ergonomic (issue), but as of now, the best approach is still to preprocess or branch manually, or use the internal maps if you're comfortable with the risk.

    If these workarounds help or you have further questions, feel free to close the issue or ask for more details!

    To reply, just mention @dosu.


    Docs are dead. Just use Dosu.

    Leave Feedback Ask Dosu about zod

  2. gebsh commented on Oct 20, 2025

    @gebsh
    Contributor

    It'd also be nice if the $ZodIssueInvalidUnion error exposed a list of valid variants along the discriminator property. In case of an invalid discriminator value, I'd like to return an error message that enumerates allowed values. This can be done by using internal APIs, but it'd be preferable not to have to rely on internals.

    import { z } from 'zod';
    
    const errorMap: z.core.$ZodErrorMap = (issue) => {
        switch (issue.code) {
            case 'invalid_union': {
                const { discriminator } = issue;
    
                if (discriminator !== undefined) {
                    const schema = issue.inst as z.ZodDiscriminatedUnion<readonly z.ZodObject[]>;
                    // It'd be nice if the following variable was an issue's property.
                    const variants = schema.options
                        .map(option => option.shape[discriminator]._zod.values!)
                        .reduce<z.util.PrimitiveSet>((acc, curr) => acc.union(curr), new Set());
                    const variantsText = Array.from(variants, variant => JSON.stringify(variant)).join(', ');
    
                    return `expected one of: ${variantsText}`;
                }
    
                return undefined;
            }
            default:
                return undefined;
        }
    };
    
    const schema = z.discriminatedUnion('test', [
        z.object({ test: z.null() }),
        z.object({ test: z.literal('foo') }),
        z.object({ test: z.literal('bar') }),
    ], { error: errorMap });
    
    const result = schema.safeParse({ test: '' });
    
    console.log(result.error?.issues);
    // [
    //   {
    //     code: 'invalid_union',
    //     errors: [],
    //     note: 'No matching discriminator',
    //     discriminator: 'test',
    //     path: [ 'test' ],
    //     message: 'expected one of: null, "foo", "bar"'
    //   }
    // ]
  3. dosubot commented on Jan 24, 2026

    @dosubot

    Hi, @akomm. I'm Dosu, and I'm helping the Zod team manage their backlog and am marking this issue as stale.

    Issue Summary:

    • You raised concerns about the ergonomics and type safety of discriminatedUnion usage, specifically the need for manual mapping or type assertions.
    • You also noted that treeifyError does not properly discriminate union errors for clearer error reporting.
    • Zod 4.0.10 introduced a discriminator property on invalid_union errors, but ergonomic mapping and error discrimination remain limited.
    • A community contributor proposed enhancing invalid_union errors to expose valid discriminator variants to improve error clarity.
    • The issue remains unresolved with ongoing challenges around ergonomic and type-safe discriminated union handling.

    Next Steps:

    • Please let me know if this issue is still relevant with the latest version of Zod, and if so, you can keep the discussion open by commenting here.
    • Otherwise, I will automatically close this issue in 7 days.

    Thanks for your understanding and contribution!

  4. akomm commented on Jan 26, 2026

    @akomm
    Author

    Still relevant.

  5. removed
    staleNo activity in last 60 days
    on Jan 26, 2026
  6. dokson commented on May 4, 2026

    @dokson
    Contributor

    Hi — opened #5947 which addresses the second point of the original issue ("Access individual types via discriminant value").

    It exposes two new public members on ZodDiscriminatedUnion:

    • schema.get(value) — returns the option schema matching the literal discriminator value, narrowed at the type level (handles members declared via z.literal([...]) with multiple values).
    • schema.optionsMap — a ReadonlyMap<DiscriminatorValue, OptionSchema> for iteration / introspection.

    Note that the first point (treeifyError losing per-member discrimination) is partly mitigated by this 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) };
    }

    A first-class helper (e.g. treeifyDiscriminatedUnionError(schema, error) returning a discriminated tree per member) would still be valuable to avoid the double-parse and to remove the manual narrowing — happy to follow up with that as a separate PR if there's interest, otherwise this issue could be reduced in scope to just the treeifyError helper.

    Also worth noting: the related #5670 (closed by #5723, in v4.4.0+) already added options: Primitive[] to the invalid_union issue when the discriminator doesn't match, so the related request from @gebsh upthread is already covered via issue.options.

  7. akomm commented on May 7, 2026

    @akomm
    Author

    Yes.
    Image

    This is something I'm doing right now and was mentioned in my initial post:

    Image

    Your PR would improve the situation with this approach indeed. The above mentioned approach right now requires me to directly import the disc-union types around the disc-union. e. G. import {TypeAFromUnion, TypeBFromUnion}

    Changes on the disc-union are not tracked this way and code might break or handle the cases in a non-exhaustive way, requiring further out of the disc-union mapping to ensure exhaustiveness.

    Your PR would remove the need to import and pick the individual types around the disc-enum and simplify, when needed, exhaustiveness.

  8. colinhacks commented on Sep 1, 2026

    @colinhacks
    Owner

    z.getDiscriminatedOption() shipped in 4.5.0 (#5947). It takes a discriminator value and hands back the matching member, which covers the access-by-discriminant half of this.

    The error-tree typing is the general unsoundness of typing errors by output rather than anything specific to discriminated unions — z.treeifyError<unknown>(errors) opts out of that assumption. Closing this out.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions