Skip to content

discriminatedUnion fails on encode() when discriminator is a ZodCodec #5593

@MadL0rd

Description

@MadL0rd

Description

When using z.discriminatedUnion with a discriminator field implemented as a ZodCodec,
decode() works correctly, but encode() fails at runtime with
No matching discriminator.

This happens even though:

  • the types of decode() / encode() inputs are correct
  • the codec itself is fully bidirectional
  • the decoded domain object is valid

The issue appears to be that discriminatedUnion attempts to select a branch
before encoding the discriminator field, using the domain value instead of
the encoded (raw) value.


Reproducible example

const LiteralTestCodec1 = z.codec(z.literal(1), z.literal('one'), {
    decode: (raw) => 'one' as const,
    encode: (domain) => 1 as const,
})

const LiteralTestCodec2 = z.codec(z.literal(2), z.literal('two'), {
    decode: (raw) => 'two' as const,
    encode: (domain) => 2 as const,
})

const TestUnionSchema = z.discriminatedUnion(
    'type',
    [
        z.object({
            type: LiteralTestCodec1,
            valueString: z.string(),
        }),
        z.object({
            type: LiteralTestCodec2,
            valueNumber: z.number(),
        }),
    ]
)

const domain = TestUnionSchema.decode({
    type: 1,
    valueString: 'test value',
})

const raw = TestUnionSchema.encode({
    type: 'one',
    valueString: 'test value',
})

Actual result

decode() works correctly.

encode() throws the following error:

ZodError: [
  {
    "code": "invalid_union",
    "errors": [],
    "note": "No matching discriminator",
    "discriminator": "type",
    "path": ["type"],
    "message": "Invalid input"
  }
]

Expected behavior

Given that:

  • the discriminator codec is bidirectional
  • encode() should produce a valid raw representation

encode() is expected to succeed and return:

{
    type: 1,
    valueString: "test value"
}

Notes / Workaround

At the application level, this issue can be worked around by enabling
{ unionFallback: true } when creating the discriminatedUnion.

This forces Zod to fall back to full union validation instead of the fast
discriminator-based path.

However, this behavior is surprising when combining:

  • z.discriminatedUnion
  • codecs used as discriminator fields
  • encode() direction

Possible directions

Below are concrete implementation-oriented ideas based on the current internal behavior of ZodDiscriminatedUnion (not prescriptions).

1. Make discriminator resolution context-aware

Currently, branch selection in ZodDiscriminatedUnion is based on a precomputed map:

const opt = disc.value.get(input?.[def.discriminator] as any);
if (opt) {
  return opt._zod.run(payload, ctx) as any;
}

Where disc is a Map computed once from propValues, derived by recursively inspecting each option schema and then cached.

For ZodCodec, propValues are defined as:

util.defineLazy(inst._zod, "propValues", () => def.in._zod.propValues);

This means:

  • discriminator values are taken exclusively from the in schema
  • there is no context available to distinguish decode vs encode
  • during encode(), discriminator resolution still relies on forward (in) values

One possible direction would be to make propValues resolution context-aware, for example by turning it into a function that receives encoding direction from the parsing context (e.g. ctx.direction: "forward" | "backward"), allowing codecs to expose discriminator values appropriate to the current direction.


2. Introduce a backward discriminator path

An alternative (more explicit) approach would be to keep the existing fast-path intact, but add a mirrored mechanism for the backward direction:

  • introduce propValuesBackward (mirroring propValues)
  • derive it from def.out instead of def.in for codecs
  • build and cache a separate discBackward map
  • select between disc and discBackward based on encoding direction

This would preserve:

  • current performance characteristics
  • clear separation of forward (decode) and backward (encode) discriminator resolution

while allowing discriminators implemented as codecs to work symmetrically.


Documentation note

Regardless of implementation, this behavior could also be documented explicitly as a current limitation of combining:

  • z.discriminatedUnion
  • codecs used as discriminator fields
  • encode() direction

See: https://zod.dev/codecs


Question

Is this behavior considered an intentional limitation of discriminatedUnion,
or would a change in discriminator resolution (or explicit documentation of the limitation) be acceptable?

If this aligns with the intended direction of the API, I am willing to prepare a PR.

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