-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Description
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
inschema - there is no context available to distinguish
decodevsencode - 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(mirroringpropValues) - derive it from
def.outinstead ofdef.infor codecs - build and cache a separate
discBackwardmap - select between
discanddiscBackwardbased 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
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.