Skip to content

Commit 65b2374

Browse files
authored
fix(conform-zod): coercion for composed discriminated unions (#969)
1 parent f75e567 commit 65b2374

File tree

3 files changed

+138
-9
lines changed

3 files changed

+138
-9
lines changed

.changeset/wise-dragons-develop.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@conform-to/zod': patch
3+
---
4+
5+
fix(conform-zod): coercion for composed discriminated unions

packages/conform-zod/v4/coercion.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { $ZodObjectDef, $ZodType, $ZodTypes } from 'zod/v4/core';
1+
import type {
2+
$ZodDiscriminatedUnionDef,
3+
$ZodObjectDef,
4+
$ZodType,
5+
$ZodTypes,
6+
} from 'zod/v4/core';
27
import { lazy, pipe, transform } from 'zod/v4-mini';
38

49
/**
@@ -274,7 +279,23 @@ export function enableTypeCoercion<Schema extends $ZodType>(
274279
new constr({
275280
...def,
276281
options: def.options.map((item) => {
277-
const objectDef = item._zod.def as $ZodObjectDef;
282+
const objectDef = item._zod.def as
283+
| $ZodObjectDef
284+
| $ZodDiscriminatedUnionDef;
285+
const setOriginalPropValues = (
286+
object: $ZodType<unknown, unknown>,
287+
) => {
288+
// The discriminate key is obtained from the defined Object.
289+
// If you regenerate the Object schema, the `propValues` property disappears. Therefore, set the one obtained from the original Object.
290+
// https://github.com/colinhacks/zod/blob/22ab436bc214d86d740e78f33ae6834d28ddc152/packages/zod/src/v4/core/schemas.ts#L1949-L1963
291+
object._zod.propValues = item._zod.propValues;
292+
// @ts-expect-error: The `disc` property was used up to version 3.25.34, but was changed to the `propValues` property from version 3.25.35 onwards.
293+
object._zod.disc = item._zod.disc;
294+
return object;
295+
};
296+
if (objectDef.type !== 'object') {
297+
return setOriginalPropValues(enableTypeCoercion(item, options));
298+
}
278299
const object = new item._zod.constr({
279300
...objectDef,
280301
shape: Object.fromEntries(
@@ -284,13 +305,7 @@ export function enableTypeCoercion<Schema extends $ZodType>(
284305
),
285306
}) as $ZodType<unknown, {}>;
286307

287-
// The discriminate key is obtained from the defined Object.
288-
// If you regenerate the Object schema, the `propValues` property disappears. Therefore, set the one obtained from the original Object.
289-
// https://github.com/colinhacks/zod/blob/22ab436bc214d86d740e78f33ae6834d28ddc152/packages/zod/src/v4/core/schemas.ts#L1949-L1963
290-
object._zod.propValues = item._zod.propValues;
291-
// @ts-expect-error: The `disc` property was used up to version 3.25.34, but was changed to the `propValues` property from version 3.25.35 onwards.
292-
object._zod.disc = item._zod.disc;
293-
return object;
308+
return setOriginalPropValues(object);
294309
}),
295310
}) as $ZodType<unknown, {}>,
296311
);

packages/conform-zod/v4/tests/coercion/schema/discriminatedUnion.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
literal,
88
number,
99
boolean,
10+
extend,
1011
} from 'zod/v4-mini';
1112
import { getResult } from '../../../../tests/helpers/zod';
1213

@@ -126,5 +127,113 @@ describe('coercion', () => {
126127
},
127128
});
128129
});
130+
131+
test('should pass composed discriminatedUnion', () => {
132+
const base = z.object({
133+
status: z.literal('failed'),
134+
message: z.string(),
135+
});
136+
const schema = z.discriminatedUnion('status', [
137+
z.object({ status: z.literal('success'), data: z.string() }),
138+
z.discriminatedUnion('code', [
139+
base.extend({ code: z.literal(400) }),
140+
base.extend({ code: z.literal(401) }),
141+
base.extend({ code: z.literal(500) }),
142+
]),
143+
]);
144+
145+
const baseWithMini = object({
146+
status: literal('failed'),
147+
message: z.string(),
148+
});
149+
const schemaWithMini = discriminatedUnion('status', [
150+
object({ status: literal('success'), data: z.string() }),
151+
discriminatedUnion('code', [
152+
extend(baseWithMini, { code: literal(400) }),
153+
extend(baseWithMini, { code: literal(401) }),
154+
extend(baseWithMini, { code: literal(500) }),
155+
]),
156+
]);
157+
158+
expect(
159+
getResult(
160+
coerceFormValue(schema).safeParse({
161+
status: 'success',
162+
data: 'test',
163+
}),
164+
),
165+
).toEqual({
166+
success: true,
167+
data: {
168+
status: 'success',
169+
data: 'test',
170+
},
171+
});
172+
expect(
173+
getResult(
174+
coerceFormValue(schema).safeParse({
175+
status: 'failed',
176+
message: 'error',
177+
code: 400,
178+
}),
179+
),
180+
).toEqual({
181+
success: true,
182+
data: {
183+
status: 'failed',
184+
message: 'error',
185+
code: 400,
186+
},
187+
});
188+
expect(
189+
getResult(coerceFormValue(schema).safeParse({ status: 'none' })),
190+
).toEqual({
191+
success: false,
192+
error: {
193+
status: ['Invalid input'],
194+
},
195+
});
196+
197+
expect(
198+
getResult(
199+
coerceFormValue(schemaWithMini).safeParse({
200+
status: 'success',
201+
data: 'test',
202+
}),
203+
),
204+
).toEqual({
205+
success: true,
206+
data: {
207+
status: 'success',
208+
data: 'test',
209+
},
210+
});
211+
expect(
212+
getResult(
213+
coerceFormValue(schemaWithMini).safeParse({
214+
status: 'failed',
215+
message: 'error',
216+
code: 400,
217+
}),
218+
),
219+
).toEqual({
220+
success: true,
221+
data: {
222+
status: 'failed',
223+
message: 'error',
224+
code: 400,
225+
},
226+
});
227+
expect(
228+
getResult(
229+
coerceFormValue(schemaWithMini).safeParse({ status: 'none' }),
230+
),
231+
).toEqual({
232+
success: false,
233+
error: {
234+
status: ['Invalid input'],
235+
},
236+
});
237+
});
129238
});
130239
});

0 commit comments

Comments
 (0)