Skip to content

Commit c5863e2

Browse files
committed
Fixed JSON Schema for non-representable types in Zod 4 adapter
1 parent 1d5808a commit c5863e2

File tree

7 files changed

+202
-7
lines changed

7 files changed

+202
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Fixed
1515

16+
- Fixed JSON Schema for non-representable types in Zod 4 adapter, it now handles `Set` and `Map` properly. [#617](https://github.com/ciscoheat/sveltekit-superforms/issues/617)
1617
- Possibly fixed the SuperDebug broken import on Svelte 5 in enforced runes mode [#599](https://github.com/ciscoheat/sveltekit-superforms/issues/599)
1718
- Zod 4 error messages should now take the current locale into account as default. [#618](https://github.com/ciscoheat/sveltekit-superforms/issues/618), [#639](https://github.com/ciscoheat/sveltekit-superforms/issues/639)
1819
- Zod 3 fix for URL parsing - A default boolean value of `true` returned `false` when parsing a URL with `superValidate`. [#633](https://github.com/ciscoheat/sveltekit-superforms/issues/633)

src/lib/adapters/zod4.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,40 @@ const defaultJSONSchemaOptions = {
4949
} else if (def.type === 'bigint') {
5050
ctx.jsonSchema.type = 'string';
5151
ctx.jsonSchema.format = 'bigint';
52+
} else if (def.type === 'set') {
53+
// Handle z.set() - convert to array with uniqueItems
54+
ctx.jsonSchema.type = 'array';
55+
ctx.jsonSchema.uniqueItems = true;
56+
// If there's a default value, convert Set to Array
57+
if ('default' in ctx.jsonSchema && ctx.jsonSchema.default instanceof Set) {
58+
ctx.jsonSchema.default = Array.from(ctx.jsonSchema.default);
59+
}
60+
} else if (def.type === 'map') {
61+
// Handle z.map() - convert to array of [key, value] tuples
62+
ctx.jsonSchema.type = 'array';
63+
ctx.jsonSchema.format = 'map';
64+
// If there's a default value, convert Map to Array
65+
if ('default' in ctx.jsonSchema && ctx.jsonSchema.default instanceof Map) {
66+
ctx.jsonSchema.default = Array.from(ctx.jsonSchema.default);
67+
}
68+
} else if (def.type === 'default') {
69+
// Handle z.default() wrapping unrepresentable types
70+
// The default value was already serialized by Zod, which converts Set/Map to {}
71+
// We need to get the original value and convert it properly
72+
const innerDef = def.innerType._zod.def;
73+
if (innerDef.type === 'set' && def.defaultValue instanceof Set) {
74+
// Set the proper schema type for sets
75+
ctx.jsonSchema.type = 'array';
76+
ctx.jsonSchema.uniqueItems = true;
77+
// Convert the default value from Set to Array
78+
ctx.jsonSchema.default = Array.from(def.defaultValue);
79+
} else if (innerDef.type === 'map' && def.defaultValue instanceof Map) {
80+
// Set the proper schema type for maps
81+
ctx.jsonSchema.type = 'array';
82+
ctx.jsonSchema.format = 'map';
83+
// Convert the default value from Map to Array of tuples
84+
ctx.jsonSchema.default = Array.from(def.defaultValue);
85+
}
5286
}
5387
}
5488
} satisfies Options;

src/lib/jsonSchema/schemaDefaults.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ function formatDefaultValue(type: SchemaType, value: unknown) {
176176
switch (type) {
177177
case 'set':
178178
return Array.isArray(value) ? new Set(value) : value;
179+
case 'map':
180+
return Array.isArray(value) ? new Map(value) : value;
179181
case 'Date':
180182
case 'date':
181183
case 'unix-time':
@@ -217,6 +219,8 @@ export function defaultValue(type: SchemaType, enumType: unknown[] | undefined):
217219
return BigInt(0);
218220
case 'set':
219221
return new Set();
222+
case 'map':
223+
return new Map();
220224
case 'symbol':
221225
return Symbol();
222226
case 'undefined':

src/lib/jsonSchema/schemaInfo.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type SchemaType =
1212
| 'any'
1313
| 'symbol'
1414
| 'set'
15+
| 'map'
1516
| 'null'
1617
| 'undefined';
1718

@@ -27,7 +28,7 @@ export type SchemaInfo = {
2728
required?: string[];
2829
};
2930

30-
const conversionFormatTypes = ['unix-time', 'bigint', 'any', 'symbol', 'set', 'int64'];
31+
const conversionFormatTypes = ['unix-time', 'bigint', 'any', 'symbol', 'set', 'map', 'int64'];
3132

3233
/**
3334
* Normalizes the different kind of schema variations (anyOf, union, const null, etc)
@@ -112,8 +113,8 @@ function schemaTypes(
112113
}
113114

114115
if (types.includes('array') && schema.uniqueItems) {
115-
const i = types.findIndex((t) => t != 'array');
116-
types[i] = 'set';
116+
const i = types.findIndex((t) => t === 'array');
117+
if (i !== -1) types[i] = 'set';
117118
} else if (schema.format && conversionFormatTypes.includes(schema.format)) {
118119
types.unshift(schema.format as SchemaType);
119120

src/tests/JSONSchema.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,11 +290,11 @@ describe('Default values', () => {
290290
productId: 0,
291291
productName: '',
292292
price: 234,
293-
tags: [],
294-
dimensions: { length: 0, width: 0, height: 0 }
293+
tags: new Set(),
294+
dimensions: { length: 0, width: 0, height: 0 },
295+
warehouseLocation: undefined
295296
});
296297
});
297-
298298
it('should map the default value of a union (anyOf) if only one default value exists.', () => {
299299
expect(defaultValues(defaultTestSchema)).toEqual({
300300
gender: 'other',

src/tests/formData.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ describe('FormData parsing', () => {
6565

6666
expect(parsed.posted).toEqual(true);
6767
expect(parsed.data.date.toISOString().substring(0, 10)).toBe('2023-12-02');
68-
expect({ ...parsed.data, date: undefined }).toEqual({ ...data, date: undefined });
68+
69+
// The set field should be a Set (which removes duplicates)
70+
const expectedData = { ...data, date: undefined, set: new Set(['a', 'b', 'c']) };
71+
expect({ ...parsed.data, date: undefined }).toEqual(expectedData);
6972
});
7073

7174
it('should throw an error if literals are different from the other types', () => {

src/tests/issue-617.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { z } from 'zod/v4';
3+
import { zod, zodToJSONSchema } from '$lib/adapters/zod4.js';
4+
import { superValidate } from '$lib/server/index.js';
5+
6+
describe('issue-617: z.set() and z.map() with default values', () => {
7+
describe('JSON Schema generation', () => {
8+
it('should generate correct schema for z.set() with empty default', () => {
9+
const schema = z.object({
10+
set: z.set(z.number()).default(new Set())
11+
});
12+
13+
const jsonSchema = zodToJSONSchema(schema);
14+
expect(jsonSchema.properties?.set).toMatchObject({
15+
type: 'array',
16+
uniqueItems: true,
17+
default: []
18+
});
19+
});
20+
21+
it('should generate correct schema for z.set() with pre-filled default', () => {
22+
const schema = z.object({
23+
set: z.set(z.number()).default(new Set([1, 2, 3]))
24+
});
25+
26+
const jsonSchema = zodToJSONSchema(schema);
27+
expect(jsonSchema.properties?.set).toMatchObject({
28+
type: 'array',
29+
uniqueItems: true,
30+
default: [1, 2, 3]
31+
});
32+
});
33+
34+
it('should generate correct schema for z.map() with empty default', () => {
35+
const schema = z.object({
36+
map: z.map(z.string(), z.number()).default(new Map())
37+
});
38+
39+
const jsonSchema = zodToJSONSchema(schema);
40+
expect(jsonSchema.properties?.map).toMatchObject({
41+
type: 'array',
42+
default: []
43+
});
44+
});
45+
46+
it('should generate correct schema for z.map() with pre-filled default', () => {
47+
const schema = z.object({
48+
map: z.map(z.string(), z.number()).default(
49+
new Map([
50+
['a', 1],
51+
['b', 2]
52+
])
53+
)
54+
});
55+
56+
const jsonSchema = zodToJSONSchema(schema);
57+
expect(jsonSchema.properties?.map).toMatchObject({
58+
type: 'array',
59+
default: [
60+
['a', 1],
61+
['b', 2]
62+
]
63+
});
64+
});
65+
});
66+
67+
it('should handle z.set() with empty default', async () => {
68+
const schema = z.object({
69+
set: z.set(z.number()).default(new Set())
70+
});
71+
72+
const form = await superValidate(zod(schema));
73+
expect(form.data.set).toBeInstanceOf(Set);
74+
expect(form.data.set.size).toBe(0);
75+
});
76+
77+
it('should handle z.set() with pre-filled default values', async () => {
78+
const schema = z.object({
79+
set: z.set(z.number()).default(new Set([1, 2, 3]))
80+
});
81+
82+
const jsonSchema = zodToJSONSchema(schema);
83+
expect(jsonSchema.properties?.set).toMatchObject({
84+
type: 'array',
85+
uniqueItems: true,
86+
default: [1, 2, 3]
87+
});
88+
89+
const form = await superValidate(zod(schema));
90+
expect(form.data.set).toBeInstanceOf(Set);
91+
expect(form.data.set.size).toBe(3);
92+
expect(form.data.set.has(1)).toBe(true);
93+
expect(form.data.set.has(2)).toBe(true);
94+
expect(form.data.set.has(3)).toBe(true);
95+
});
96+
97+
it('should handle z.map() with empty default', async () => {
98+
const schema = z.object({
99+
map: z.map(z.string(), z.number()).default(new Map())
100+
});
101+
102+
const jsonSchema = zodToJSONSchema(schema);
103+
expect(jsonSchema.properties?.map).toMatchObject({
104+
type: 'array',
105+
default: []
106+
});
107+
108+
const form = await superValidate(zod(schema));
109+
expect(form.data.map).toBeInstanceOf(Map);
110+
expect(form.data.map.size).toBe(0);
111+
});
112+
113+
it('should handle z.map() with pre-filled default values', async () => {
114+
const schema = z.object({
115+
map: z.map(z.string(), z.number()).default(
116+
new Map([
117+
['a', 1],
118+
['b', 2]
119+
])
120+
)
121+
});
122+
123+
const jsonSchema = zodToJSONSchema(schema);
124+
expect(jsonSchema.properties?.map).toMatchObject({
125+
type: 'array',
126+
default: [
127+
['a', 1],
128+
['b', 2]
129+
]
130+
});
131+
132+
const form = await superValidate(zod(schema));
133+
expect(form.data.map).toBeInstanceOf(Map);
134+
expect(form.data.map.size).toBe(2);
135+
expect(form.data.map.get('a')).toBe(1);
136+
expect(form.data.map.get('b')).toBe(2);
137+
});
138+
139+
it('should allow Set operations on default values', async () => {
140+
const schema = z.object({
141+
tags: z.set(z.string()).default(new Set(['typescript', 'svelte']))
142+
});
143+
144+
const form = await superValidate(zod(schema));
145+
146+
// This should not throw - the regression would cause this to fail
147+
expect(() => form.data.tags.has('typescript')).not.toThrow();
148+
expect(form.data.tags.has('typescript')).toBe(true);
149+
expect(form.data.tags.has('svelte')).toBe(true);
150+
expect(form.data.tags.has('react')).toBe(false);
151+
});
152+
});

0 commit comments

Comments
 (0)