Skip to content

Commit 46f05eb

Browse files
committed
Zod 4 discriminatedUnion tests
1 parent cd28d4b commit 46f05eb

File tree

4 files changed

+298
-2
lines changed

4 files changed

+298
-2
lines changed

src/lib/adapters/zod4.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,24 @@ import { memoize } from '$lib/memoize.js';
1919

2020
type Options = NonNullable<Parameters<typeof toJSONSchema>[1]>;
2121

22-
export type ZodValidationSchema = $ZodObject | $ZodDiscriminatedUnion<$ZodObject[]>;
22+
export type ZodValidationSchema =
23+
| $ZodObject
24+
| $ZodDiscriminatedUnion<
25+
(
26+
| $ZodObject
27+
| $ZodDiscriminatedUnion<
28+
(
29+
| $ZodObject
30+
| $ZodDiscriminatedUnion<
31+
(
32+
| $ZodObject
33+
| $ZodDiscriminatedUnion<($ZodObject | $ZodDiscriminatedUnion<$ZodObject[]>)[]>
34+
)[]
35+
>
36+
)[]
37+
>
38+
)[]
39+
>;
2340

2441
const defaultJSONSchemaOptions = {
2542
unrepresentable: 'any',

src/tests/superValidate.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,47 @@ describe('Valibot', () => {
482482
expect(form.data).toEqual({ id: BigInt('123456789123456789') });
483483
});
484484

485+
/*
486+
it('should handle lazy', async () => {
487+
interface ChecklistItem {
488+
childItems: ChecklistItem[];
489+
}
490+
491+
//for recursive it need type GenericSchema with interface of recursive object
492+
const checklistItemSchema: v.GenericSchema<ChecklistItem> = v.object({
493+
childItems: v.array(v.lazy(() => checklistItemSchema)) //recursive definition
494+
});
495+
496+
const checklistSectionSchema = v.object({
497+
items: v.array(checklistItemSchema)
498+
});
499+
500+
const form = await superValidate(
501+
{
502+
items: [
503+
{
504+
childItems: []
505+
}
506+
]
507+
},
508+
valibot(checklistSectionSchema, {
509+
definitions: {
510+
childItems: checklistItemSchema
511+
}
512+
})
513+
);
514+
515+
expect(form.valid).toBe(true);
516+
expect(form.data).toEqual({
517+
items: [
518+
{
519+
childItems: []
520+
}
521+
]
522+
});
523+
});
524+
*/
525+
485526
/*
486527
it('should work with FormPathLeaves and brand', async () => {
487528
const schema = v.object({ id: v.brand(v.string(), 'Id') });
@@ -584,7 +625,7 @@ describe('ajv', () => {
584625

585626
/////////////////////////////////////////////////////////////////////
586627

587-
describe('Zod', () => {
628+
describe('Zod 3', () => {
588629
const schema = z
589630
.object({
590631
name: z.string().default('Unknown'),

src/tests/zod4Union.test.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import type { Infer, ValidationAdapter } from '$lib/adapters/adapters.js';
2+
import { zod } from '$lib/adapters/zod4.js';
3+
import { superValidate } from '$lib/superValidate.js';
4+
import { stringify } from 'devalue';
5+
import { assert, describe, expect, test } from 'vitest';
6+
import { z } from 'zod/v4';
7+
8+
async function validate<T extends Record<string, unknown>>(
9+
data: T,
10+
schema: ValidationAdapter<T>,
11+
strict = false
12+
) {
13+
const formInput = new FormData();
14+
15+
formInput.set('__superform_json', stringify(data));
16+
try {
17+
return await superValidate(formInput, schema, { strict });
18+
} catch (err) {
19+
console.error(err);
20+
//
21+
throw err;
22+
}
23+
}
24+
25+
describe('New discriminatedUnion features', () => {
26+
test('Unions and pipes', async () => {
27+
const MyResult = z.discriminatedUnion('status', [
28+
// simple literal
29+
z.object({ status: z.literal('aaa'), data: z.string() }),
30+
// union discriminator
31+
z.object({ status: z.union([z.literal('bbb'), z.literal('ccc')]) }),
32+
// pipe discriminator
33+
z.object({ status: z.literal('fail').transform((val) => val.toUpperCase()) })
34+
]);
35+
36+
const form = await validate({ status: 'bbb' }, zod(MyResult));
37+
expect(form.valid).toBe(true);
38+
});
39+
40+
test('Composed unions', async () => {
41+
const BaseError = z.object({ status: z.literal('failed'), message: z.string() });
42+
43+
const MyResult = z.discriminatedUnion('status', [
44+
z.object({ status: z.literal('success'), data: z.string() }),
45+
z.discriminatedUnion('code', [
46+
BaseError.extend({ code: z.literal(400) }),
47+
BaseError.extend({ code: z.literal(401) }),
48+
BaseError.extend({ code: z.literal(500) })
49+
])
50+
]);
51+
52+
const form = await validate({ status: 'failed', message: 'FAIL', code: 401 }, zod(MyResult));
53+
expect(form.valid).toBe(true);
54+
55+
assert(form.data.status === 'failed');
56+
assert(form.data.message === 'FAIL');
57+
assert(form.data.code === 401);
58+
});
59+
});
60+
61+
describe('Default discriminated union values 1', () => {
62+
const schema = z.discriminatedUnion('type', [
63+
z.object({ type: z.literal('empty') }),
64+
z.object({ type: z.literal('extra'), options: z.string().array() })
65+
]);
66+
67+
test('Union with schema 1', async () => {
68+
const form = await validate({ type: 'empty' }, zod(schema));
69+
expect(form.valid).toBe(true);
70+
expect(form.data).toEqual({ type: 'empty' });
71+
});
72+
73+
test('Union with schema 2', async () => {
74+
const form = await validate({ type: 'extra' } as Infer<typeof schema>, zod(schema), true);
75+
expect(form.valid).toBe(false);
76+
expect(form.data).toEqual({ type: 'extra', options: [] });
77+
});
78+
79+
test('Nested discriminated union with default value', async () => {
80+
const nested = z.object({
81+
addresses: z.object({
82+
additional: z
83+
.discriminatedUnion('type', [
84+
z.object({
85+
type: z.literal('poBox'),
86+
name: z.string().min(1, 'min len').max(10, 'max len')
87+
}),
88+
z.object({
89+
type: z.literal('none')
90+
})
91+
])
92+
.default({
93+
type: 'none'
94+
})
95+
})
96+
});
97+
98+
const form1 = await superValidate(zod(nested));
99+
expect(form1.data.addresses.additional).toEqual({ type: 'none' });
100+
101+
const form2 = await validate(
102+
{
103+
addresses: {
104+
additional: {
105+
type: 'poBox',
106+
name: '#123'
107+
}
108+
}
109+
},
110+
zod(nested)
111+
);
112+
113+
expect(form2.valid).toBe(true);
114+
assert(form2.data.addresses.additional?.type === 'poBox');
115+
expect(form2.data.addresses.additional.name).toBe('#123');
116+
});
117+
});
118+
119+
describe('Default discriminated union values 2', () => {
120+
const ZodSchema = z.object({
121+
addresses: z.object({
122+
additional: z.discriminatedUnion('type', [
123+
z.object({
124+
type: z.literal('poBox'),
125+
name: z.string().min(1, 'min len').max(10, 'max len')
126+
}),
127+
z.object({
128+
type: z.literal('none')
129+
})
130+
])
131+
})
132+
});
133+
const FormSchema = zod(ZodSchema);
134+
type FormSchema = (typeof FormSchema)['defaults'];
135+
136+
test('Bad', async () => {
137+
const data = {
138+
addresses: {
139+
additional: {
140+
type: 'poBox',
141+
name: ''
142+
}
143+
}
144+
} satisfies FormSchema;
145+
await validate(data, FormSchema);
146+
});
147+
148+
test('Good', async () => {
149+
const data = {
150+
addresses: {
151+
additional: {
152+
type: 'none'
153+
}
154+
}
155+
} satisfies FormSchema;
156+
await validate(data, FormSchema);
157+
});
158+
});
159+
160+
test('Default value with *matching* type in nested discriminated union with superRefine', async () => {
161+
const ZodSchema2 = z
162+
.object({
163+
type: z.literal('additional'),
164+
additional: z
165+
.discriminatedUnion('type', [
166+
z.object({
167+
type: z.literal('same'),
168+
address: z.string().nullable()
169+
}),
170+
z.object({
171+
type: z.literal('different'),
172+
address: z.string()
173+
})
174+
])
175+
.default({
176+
type: 'same',
177+
address: null
178+
})
179+
})
180+
.superRefine((_data, ctx) => {
181+
ctx.addIssue({
182+
code: z.ZodIssueCode.custom,
183+
path: ['addresses', 'additional', 'name'],
184+
message: 'error'
185+
});
186+
});
187+
188+
const FormSchema = zod(ZodSchema2);
189+
type FormSchema = (typeof FormSchema)['defaults'];
190+
const data = {
191+
type: 'additional',
192+
additional: {
193+
type: 'different',
194+
address: '123 Main St'
195+
}
196+
} satisfies FormSchema;
197+
await validate(data, FormSchema);
198+
});

src/tests/zodUnion.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,43 @@ describe('Default discriminated union values 2', () => {
120120
await validate(data, FormSchema);
121121
});
122122
});
123+
124+
test('Default value with *matching* type in nested discriminated union with superRefine', async () => {
125+
const ZodSchema2 = z
126+
.object({
127+
type: z.literal('additional'),
128+
additional: z
129+
.discriminatedUnion('type', [
130+
z.object({
131+
type: z.literal('same'),
132+
address: z.string().nullable()
133+
}),
134+
z.object({
135+
type: z.literal('different'),
136+
address: z.string()
137+
})
138+
])
139+
.default({
140+
type: 'same',
141+
address: null
142+
})
143+
})
144+
.superRefine((_data, ctx) => {
145+
ctx.addIssue({
146+
code: z.ZodIssueCode.custom,
147+
path: ['addresses', 'additional', 'name'],
148+
message: 'error'
149+
});
150+
});
151+
152+
const FormSchema = zod(ZodSchema2);
153+
type FormSchema = (typeof FormSchema)['defaults'];
154+
const data = {
155+
type: 'additional',
156+
additional: {
157+
type: 'different',
158+
address: '123 Main St'
159+
}
160+
} satisfies FormSchema;
161+
await validate(data, FormSchema);
162+
});

0 commit comments

Comments
 (0)