Skip to content

Commit b9faeae

Browse files
authored
Merge pull request #21 from hampusborgos/feature/default-values
Basic support for defaults in Schema
2 parents 116ef3d + 25650d1 commit b9faeae

File tree

5 files changed

+121
-37
lines changed

5 files changed

+121
-37
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ This library provides comprehensive support for JSON Schema Draft 2020-12 featur
233233
### Additional Features
234234
- `title` - Schema titles (carried over to Zod schemas)
235235
- `description` - Schema descriptions (carried over to Zod schemas)
236+
- `default` - Default value annotation, but ignored if it doesn't conform to the schema
236237
- Boolean schemas (`true` = allow anything, `false` = allow nothing)
237238
- Implicit type detection from constraints
238239
- Comprehensive error messages

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "zod-from-json-schema",
3-
"version": "0.5.0",
3+
"version": "0.5.1",
44
"description": "Creates Zod types from JSON Schema at runtime",
55
"main": "dist/index.js",
66
"module": "dist/index.mjs",

src/core/converter.ts

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,27 @@ import { PrimitiveHandler, RefinementHandler, TypeSchemas } from "./types";
66
import { TypeHandler } from "../handlers/primitive/type";
77
import { ConstHandler } from "../handlers/primitive/const";
88
import { EnumHandler } from "../handlers/primitive/enum";
9-
import { ImplicitStringHandler, MinLengthHandler, MaxLengthHandler, PatternHandler } from "../handlers/primitive/string";
10-
import { MinimumHandler, MaximumHandler, ExclusiveMinimumHandler, ExclusiveMaximumHandler, MultipleOfHandler } from "../handlers/primitive/number";
9+
import {
10+
ImplicitStringHandler,
11+
MinLengthHandler,
12+
MaxLengthHandler,
13+
PatternHandler,
14+
} from "../handlers/primitive/string";
15+
import {
16+
MinimumHandler,
17+
MaximumHandler,
18+
ExclusiveMinimumHandler,
19+
ExclusiveMaximumHandler,
20+
MultipleOfHandler,
21+
} from "../handlers/primitive/number";
1122
import { ImplicitArrayHandler, MinItemsHandler, MaxItemsHandler, ItemsHandler } from "../handlers/primitive/array";
1223
import { TupleHandler } from "../handlers/primitive/tuple";
13-
import { PropertiesHandler, ImplicitObjectHandler, MaxPropertiesHandler, MinPropertiesHandler } from "../handlers/primitive/object";
24+
import {
25+
PropertiesHandler,
26+
ImplicitObjectHandler,
27+
MaxPropertiesHandler,
28+
MinPropertiesHandler,
29+
} from "../handlers/primitive/object";
1430

1531
// Import refinement handlers
1632
import { NotHandler } from "../handlers/refinement/not";
@@ -25,37 +41,38 @@ import { ConstComplexHandler } from "../handlers/refinement/constComplex";
2541
import { MetadataHandler } from "../handlers/refinement/metadata";
2642
import { ProtoRequiredHandler } from "../handlers/refinement/protoRequired";
2743
import { ContainsHandler } from "../handlers/refinement/contains";
44+
import { DefaultHandler } from "../handlers/refinement/default";
2845

2946
// Initialize handlers
3047
const primitiveHandlers: PrimitiveHandler[] = [
3148
// Type constraints - should run first
3249
new ConstHandler(),
3350
new EnumHandler(),
3451
new TypeHandler(),
35-
52+
3653
// Implicit type detection - must run before other constraints
3754
new ImplicitStringHandler(),
3855
new ImplicitArrayHandler(),
3956
new ImplicitObjectHandler(),
40-
57+
4158
// String constraints
4259
new MinLengthHandler(),
4360
new MaxLengthHandler(),
4461
new PatternHandler(),
45-
62+
4663
// Number constraints
4764
new MinimumHandler(),
4865
new MaximumHandler(),
4966
new ExclusiveMinimumHandler(),
5067
new ExclusiveMaximumHandler(),
5168
new MultipleOfHandler(),
52-
69+
5370
// Array constraints - TupleHandler must run before ItemsHandler
5471
new TupleHandler(),
5572
new MinItemsHandler(),
5673
new MaxItemsHandler(),
5774
new ItemsHandler(),
58-
75+
5976
// Object constraints
6077
new MaxPropertiesHandler(),
6178
new MinPropertiesHandler(),
@@ -67,23 +84,24 @@ const refinementHandlers: RefinementHandler[] = [
6784
new ProtoRequiredHandler(),
6885
new EnumComplexHandler(),
6986
new ConstComplexHandler(),
70-
87+
7188
// Logical combinations
7289
new AllOfHandler(),
7390
new AnyOfHandler(),
7491
new OneOfHandler(),
75-
92+
7693
// Type-specific refinements
7794
new PrefixItemsHandler(),
7895
new ObjectPropertiesHandler(),
79-
96+
8097
// Array refinements
8198
new ContainsHandler(),
82-
99+
83100
// Other refinements
84101
new NotHandler(),
85102
new UniqueItemsHandler(),
86-
103+
new DefaultHandler(),
104+
87105
// Metadata last
88106
new MetadataHandler(),
89107
];
@@ -99,14 +117,14 @@ export function convertJsonSchemaToZod(schema: JSONSchema.BaseSchema | boolean):
99117

100118
// Phase 1: Initialize type schemas and apply primitive handlers
101119
const types: TypeSchemas = {};
102-
120+
103121
for (const handler of primitiveHandlers) {
104122
handler.apply(types, schema);
105123
}
106-
124+
107125
// Build array of allowed type schemas
108126
const allowedSchemas: z.ZodTypeAny[] = [];
109-
127+
110128
if (types.string !== false) {
111129
allowedSchemas.push(types.string || z.string());
112130
}
@@ -132,12 +150,12 @@ export function convertJsonSchemaToZod(schema: JSONSchema.BaseSchema | boolean):
132150
} else {
133151
// Use custom validator that rejects arrays for default object schema
134152
const objectSchema = z.custom<object>((val) => {
135-
return typeof val === 'object' && val !== null && !Array.isArray(val);
153+
return typeof val === "object" && val !== null && !Array.isArray(val);
136154
}, "Must be an object, not an array");
137155
allowedSchemas.push(objectSchema);
138156
}
139157
}
140-
158+
141159
// Create base schema
142160
let zodSchema: z.ZodTypeAny;
143161
if (allowedSchemas.length === 0) {
@@ -146,22 +164,22 @@ export function convertJsonSchemaToZod(schema: JSONSchema.BaseSchema | boolean):
146164
zodSchema = allowedSchemas[0];
147165
} else {
148166
// Check if this is an unconstrained schema (all default types enabled)
149-
const hasConstraints = Object.keys(schema).some(key =>
150-
key !== '$schema' && key !== 'title' && key !== 'description'
167+
const hasConstraints = Object.keys(schema).some(
168+
(key) => key !== "$schema" && key !== "title" && key !== "description",
151169
);
152-
170+
153171
if (!hasConstraints) {
154172
// Empty schema with no constraints should be z.any()
155173
zodSchema = z.any();
156174
} else {
157175
zodSchema = z.union(allowedSchemas as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]);
158176
}
159177
}
160-
178+
161179
// Phase 2: Apply refinement handlers
162180
for (const handler of refinementHandlers) {
163181
zodSchema = handler.apply(zodSchema, schema);
164182
}
165-
183+
166184
return zodSchema;
167-
}
185+
}

src/handlers/refinement/default.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { z } from "zod/v4";
2+
import type { JSONSchema } from "zod/v4/core";
3+
import { RefinementHandler } from "../../core/types";
4+
5+
export class DefaultHandler implements RefinementHandler {
6+
apply(zodSchema: z.ZodTypeAny, schema: JSONSchema.BaseSchema): z.ZodTypeAny {
7+
const { default: v } = schema;
8+
9+
if (v === undefined) return zodSchema;
10+
if (!zodSchema.safeParse(v).success) {
11+
// should we error-log here?
12+
return zodSchema;
13+
}
14+
15+
return zodSchema.default(v);
16+
}
17+
}

src/index.test.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, it, expect } from "vitest";
22
import { convertJsonSchemaToZod, jsonSchemaObjectToZodRawShape } from "./index";
33
import { z } from "zod/v4";
4-
import {JSONSchema} from "zod/v4/core";
4+
import { JSONSchema } from "zod/v4/core";
55

66
describe("convertJsonSchemaToZod", () => {
77
it("should correctly convert a schema with additionalProperties: {}", () => {
@@ -93,7 +93,11 @@ describe("convertJsonSchemaToZod", () => {
9393

9494
const resultSchema = z.toJSONSchema(zodSchema);
9595
// Zod v4 converts unions to anyOf instead of enum
96-
expect(resultSchema.anyOf).toEqual([{ type: "number", const: 1 }, { type: "number", const: 2 }, { type: "number", const: 3 }]);
96+
expect(resultSchema.anyOf).toEqual([
97+
{ type: "number", const: 1 },
98+
{ type: "number", const: 2 },
99+
{ type: "number", const: 3 },
100+
]);
97101
});
98102

99103
it("should correctly convert a schema with boolean enum (no type)", () => {
@@ -111,7 +115,10 @@ describe("convertJsonSchemaToZod", () => {
111115

112116
const resultSchema = z.toJSONSchema(zodSchema);
113117
// Zod v4 converts unions to anyOf instead of enum
114-
expect(resultSchema.anyOf).toEqual([{ type: "boolean", const: true }, { type: "boolean", const: false }]);
118+
expect(resultSchema.anyOf).toEqual([
119+
{ type: "boolean", const: true },
120+
{ type: "boolean", const: false },
121+
]);
115122
});
116123

117124
it("should correctly convert a schema with mixed enum (no type)", () => {
@@ -132,7 +139,12 @@ describe("convertJsonSchemaToZod", () => {
132139

133140
const resultSchema = z.toJSONSchema(zodSchema);
134141
// Zod v4 converts unions to anyOf instead of enum
135-
expect(resultSchema.anyOf).toEqual([{ type: "string", const: "red" }, { type: "number", const: 1 }, { type: "boolean", const: true }, { type: "null" }]);
142+
expect(resultSchema.anyOf).toEqual([
143+
{ type: "string", const: "red" },
144+
{ type: "number", const: 1 },
145+
{ type: "boolean", const: true },
146+
{ type: "null" },
147+
]);
136148
});
137149

138150
it("should correctly convert a schema with single item mixed enum (no type)", () => {
@@ -570,12 +582,12 @@ describe("convertJsonSchemaToZod", () => {
570582

571583
// Test that the validation works correctly
572584
expect(zodSchema.safeParse("hello").success).toBe(true); // 5 chars, within range
573-
expect(zodSchema.safeParse("hi").success).toBe(false); // 2 chars, too short
585+
expect(zodSchema.safeParse("hi").success).toBe(false); // 2 chars, too short
574586
expect(zodSchema.safeParse("this is too long").success).toBe(false); // too long
575587

576588
// Test Unicode support
577589
expect(zodSchema.safeParse("💩💩💩").success).toBe(true); // 3 graphemes
578-
expect(zodSchema.safeParse("💩").success).toBe(false); // 1 grapheme, too short
590+
expect(zodSchema.safeParse("💩").success).toBe(false); // 1 grapheme, too short
579591

580592
// Note: length constraints implemented with .refine() don't round-trip
581593
// back to JSON Schema, so we only test the validation behavior
@@ -1025,7 +1037,7 @@ describe("jsonSchemaObjectToZodRawShape", () => {
10251037
// Verify types are correct - required fields are direct types
10261038
expect(rawShape.name instanceof z.ZodString).toBe(true);
10271039
expect(rawShape.age instanceof z.ZodNumber).toBe(true);
1028-
1040+
10291041
// isActive is not in required array, so it should be optional
10301042
expect(rawShape.isActive instanceof z.ZodOptional).toBe(true);
10311043
// Check the inner type of the optional
@@ -1097,16 +1109,14 @@ describe("jsonSchemaObjectToZodRawShape", () => {
10971109
user: { email: "[email protected]" },
10981110
}),
10991111
).toThrow();
1100-
1112+
11011113
// Since user is optional at the top level, empty object should pass
1102-
expect(() =>
1103-
schema.parse({}),
1104-
).not.toThrow();
1114+
expect(() => schema.parse({})).not.toThrow();
11051115
});
11061116

11071117
it("should be usable to build custom schemas", () => {
11081118
const jsonSchema: JSONSchema.BaseSchema = {
1109-
type: "object",
1119+
type: "object",
11101120
properties: {
11111121
name: { type: "string" },
11121122
age: { type: "integer" },
@@ -1217,4 +1227,42 @@ describe("jsonSchemaObjectToZodRawShape", () => {
12171227
}),
12181228
).not.toThrow();
12191229
});
1230+
1231+
it("schema with defaults should parse empty objects", () => {
1232+
const jsonSchema = {
1233+
type: "object",
1234+
properties: {
1235+
field1: { type: "string", default: "test" },
1236+
field2: { type: "number", default: 42 },
1237+
field3: { type: "boolean", default: true },
1238+
field4: { type: "string", enum: ["test1", "test2"], default: "test2" },
1239+
field5: {
1240+
type: "object",
1241+
default: { name: "test", age: 10, isActive: true },
1242+
properties: {
1243+
name: { type: "string" },
1244+
age: { type: "integer", minimum: -43, maximum: 120 },
1245+
isActive: { type: "boolean" },
1246+
},
1247+
required: ["name", "age", "isActive"],
1248+
},
1249+
field6: { type: "string", default: 42 },
1250+
},
1251+
// No required field - all properties should be optional
1252+
};
1253+
1254+
const rawShape = jsonSchemaObjectToZodRawShape(jsonSchema);
1255+
const schema = z.object(rawShape);
1256+
1257+
// All fields should be optional
1258+
expect(() => schema.parse({})).not.toThrow();
1259+
expect(schema.parse({})).toEqual({
1260+
field1: "test",
1261+
field2: 42,
1262+
field3: true,
1263+
field4: "test2",
1264+
field5: { name: "test", age: 10, isActive: true },
1265+
// `field6` doesn't get the default, because it's not the correct type
1266+
});
1267+
});
12201268
});

0 commit comments

Comments
 (0)