@gabrielbryk/json-schema-to-zod 2.10.0 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +44 -0
- package/CHANGELOG.md +35 -0
- package/README.md +6 -33
- package/check-types-lift.sh +23 -0
- package/check-types.sh +20 -0
- package/dist/{esm/cli.js → cli.js} +0 -6
- package/dist/{esm/core → core}/analyzeSchema.js +4 -5
- package/dist/core/emitZod.js +263 -0
- package/dist/{esm/generators → generators}/generateBundle.js +225 -67
- package/dist/{esm/index.js → index.js} +6 -0
- package/dist/jsonSchemaToZod.js +17 -0
- package/dist/parsers/parseAllOf.js +125 -0
- package/dist/parsers/parseAnyOf.js +28 -0
- package/dist/{esm/parsers → parsers}/parseArray.js +27 -11
- package/dist/parsers/parseBoolean.js +4 -0
- package/dist/parsers/parseConst.js +22 -0
- package/dist/parsers/parseEnum.js +35 -0
- package/dist/{esm/parsers → parsers}/parseIfThenElse.js +11 -7
- package/dist/parsers/parseMultipleType.js +10 -0
- package/dist/parsers/parseNot.js +14 -0
- package/dist/parsers/parseNull.js +4 -0
- package/dist/parsers/parseNullable.js +12 -0
- package/dist/{esm/parsers → parsers}/parseNumber.js +4 -1
- package/dist/{esm/parsers → parsers}/parseObject.js +168 -29
- package/dist/parsers/parseOneOf.js +365 -0
- package/dist/{esm/parsers → parsers}/parseSchema.js +56 -110
- package/dist/parsers/parseSimpleDiscriminatedOneOf.js +24 -0
- package/dist/{esm/parsers → parsers}/parseString.js +29 -18
- package/dist/types/Types.d.ts +32 -4
- package/dist/types/core/analyzeSchema.d.ts +3 -2
- package/dist/types/generators/generateBundle.d.ts +0 -2
- package/dist/types/index.d.ts +6 -0
- package/dist/types/parsers/parseAllOf.d.ts +2 -2
- package/dist/types/parsers/parseAnyOf.d.ts +2 -2
- package/dist/types/parsers/parseArray.d.ts +2 -2
- package/dist/types/parsers/parseBoolean.d.ts +2 -1
- package/dist/types/parsers/parseConst.d.ts +2 -2
- package/dist/types/parsers/parseDefault.d.ts +2 -2
- package/dist/types/parsers/parseEnum.d.ts +2 -2
- package/dist/types/parsers/parseIfThenElse.d.ts +2 -2
- package/dist/types/parsers/parseMultipleType.d.ts +2 -2
- package/dist/types/parsers/parseNot.d.ts +2 -2
- package/dist/types/parsers/parseNull.d.ts +2 -1
- package/dist/types/parsers/parseNullable.d.ts +2 -2
- package/dist/types/parsers/parseNumber.d.ts +2 -2
- package/dist/types/parsers/parseObject.d.ts +2 -2
- package/dist/types/parsers/parseOneOf.d.ts +2 -2
- package/dist/types/parsers/parseSchema.d.ts +2 -2
- package/dist/types/parsers/parseSimpleDiscriminatedOneOf.d.ts +2 -2
- package/dist/types/parsers/parseString.d.ts +2 -2
- package/dist/types/utils/anyOrUnknown.d.ts +5 -4
- package/dist/types/utils/esmEmitter.d.ts +29 -0
- package/dist/types/utils/extractInlineObject.d.ts +15 -0
- package/dist/types/utils/liftInlineObjects.d.ts +21 -0
- package/dist/types/utils/namingService.d.ts +21 -0
- package/dist/types/utils/resolveRef.d.ts +7 -0
- package/dist/types/utils/schemaRepresentation.d.ts +71 -0
- package/dist/utils/anyOrUnknown.js +13 -0
- package/dist/{esm/utils → utils}/buildRefRegistry.js +4 -0
- package/dist/utils/esmEmitter.js +87 -0
- package/dist/utils/extractInlineObject.js +119 -0
- package/dist/utils/liftInlineObjects.js +476 -0
- package/dist/utils/namingService.js +58 -0
- package/dist/utils/resolveRef.js +92 -0
- package/dist/utils/schemaRepresentation.js +569 -0
- package/docs/IMPROVEMENT-PLAN.md +243 -0
- package/docs/ZOD-V4-RECURSIVE-TYPE-LIMITATIONS.md +292 -0
- package/docs/proposals/bundle-refactor.md +1 -1
- package/docs/proposals/discriminated-union-with-default.md +248 -0
- package/docs/proposals/inline-object-lifting.md +77 -0
- package/eslint.config.js +4 -2
- package/jest.config.mjs +19 -0
- package/package.json +17 -20
- package/scripts/generateWorkflowSchema.ts +0 -1
- package/dist/cjs/Types.js +0 -2
- package/dist/cjs/cli.js +0 -70
- package/dist/cjs/core/analyzeSchema.js +0 -62
- package/dist/cjs/core/emitZod.js +0 -141
- package/dist/cjs/generators/generateBundle.js +0 -365
- package/dist/cjs/index.js +0 -50
- package/dist/cjs/jsonSchemaToZod.js +0 -10
- package/dist/cjs/package.json +0 -1
- package/dist/cjs/parsers/parseAllOf.js +0 -46
- package/dist/cjs/parsers/parseAnyOf.js +0 -18
- package/dist/cjs/parsers/parseArray.js +0 -90
- package/dist/cjs/parsers/parseBoolean.js +0 -5
- package/dist/cjs/parsers/parseConst.js +0 -7
- package/dist/cjs/parsers/parseDefault.js +0 -8
- package/dist/cjs/parsers/parseEnum.js +0 -21
- package/dist/cjs/parsers/parseIfThenElse.js +0 -35
- package/dist/cjs/parsers/parseMultipleType.js +0 -10
- package/dist/cjs/parsers/parseNot.js +0 -12
- package/dist/cjs/parsers/parseNull.js +0 -5
- package/dist/cjs/parsers/parseNullable.js +0 -12
- package/dist/cjs/parsers/parseNumber.js +0 -116
- package/dist/cjs/parsers/parseObject.js +0 -315
- package/dist/cjs/parsers/parseOneOf.js +0 -53
- package/dist/cjs/parsers/parseSchema.js +0 -411
- package/dist/cjs/parsers/parseSimpleDiscriminatedOneOf.js +0 -21
- package/dist/cjs/parsers/parseString.js +0 -317
- package/dist/cjs/utils/anyOrUnknown.js +0 -14
- package/dist/cjs/utils/buildRefRegistry.js +0 -56
- package/dist/cjs/utils/cliTools.js +0 -108
- package/dist/cjs/utils/cycles.js +0 -113
- package/dist/cjs/utils/half.js +0 -7
- package/dist/cjs/utils/jsdocs.js +0 -20
- package/dist/cjs/utils/omit.js +0 -11
- package/dist/cjs/utils/resolveUri.js +0 -16
- package/dist/cjs/utils/withMessage.js +0 -21
- package/dist/cjs/zodToJsonSchema.js +0 -89
- package/dist/esm/core/emitZod.js +0 -137
- package/dist/esm/jsonSchemaToZod.js +0 -6
- package/dist/esm/package.json +0 -1
- package/dist/esm/parsers/parseAllOf.js +0 -43
- package/dist/esm/parsers/parseAnyOf.js +0 -14
- package/dist/esm/parsers/parseBoolean.js +0 -1
- package/dist/esm/parsers/parseConst.js +0 -3
- package/dist/esm/parsers/parseEnum.js +0 -17
- package/dist/esm/parsers/parseMultipleType.js +0 -6
- package/dist/esm/parsers/parseNot.js +0 -8
- package/dist/esm/parsers/parseNull.js +0 -1
- package/dist/esm/parsers/parseNullable.js +0 -8
- package/dist/esm/parsers/parseOneOf.js +0 -49
- package/dist/esm/parsers/parseSimpleDiscriminatedOneOf.js +0 -17
- package/dist/esm/utils/anyOrUnknown.js +0 -10
- package/jest.config.cjs +0 -4
- package/postcjs.cjs +0 -1
- package/postesm.cjs +0 -1
- /package/dist/{esm/Types.js → Types.js} +0 -0
- /package/dist/{esm/parsers → parsers}/parseDefault.js +0 -0
- /package/dist/{esm/utils → utils}/cliTools.js +0 -0
- /package/dist/{esm/utils → utils}/cycles.js +0 -0
- /package/dist/{esm/utils → utils}/half.js +0 -0
- /package/dist/{esm/utils → utils}/jsdocs.js +0 -0
- /package/dist/{esm/utils → utils}/omit.js +0 -0
- /package/dist/{esm/utils → utils}/resolveUri.js +0 -0
- /package/dist/{esm/utils → utils}/withMessage.js +0 -0
- /package/dist/{esm/zodToJsonSchema.js → zodToJsonSchema.js} +0 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Proposal: Discriminated Union with Default Case Detection
|
|
2
|
+
|
|
3
|
+
## Status: Blocked by Zod v4 Type System
|
|
4
|
+
|
|
5
|
+
**TL;DR**: The runtime optimization works, but Zod v4's type system prevents `ZodDiscriminatedUnion` from being nested inside `ZodUnion`. Until this is resolved upstream, we cannot implement this optimization while maintaining type safety.
|
|
6
|
+
|
|
7
|
+
## Summary
|
|
8
|
+
|
|
9
|
+
Enhance `parseOneOf` to detect and optimize JSON Schema patterns where a oneOf contains multiple variants with constant discriminator values plus a "catch-all" default variant using `not: { enum: [...] }`.
|
|
10
|
+
|
|
11
|
+
## Motivation
|
|
12
|
+
|
|
13
|
+
Consider this common JSON Schema pattern (from Serverless Workflow spec):
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
callTask:
|
|
17
|
+
oneOf:
|
|
18
|
+
- title: CallAsyncAPI
|
|
19
|
+
properties:
|
|
20
|
+
call: { const: "asyncapi" }
|
|
21
|
+
- title: CallGRPC
|
|
22
|
+
properties:
|
|
23
|
+
call: { const: "grpc" }
|
|
24
|
+
- title: CallHTTP
|
|
25
|
+
properties:
|
|
26
|
+
call: { const: "http" }
|
|
27
|
+
# ... more known variants
|
|
28
|
+
- title: CallFunction # Default/catch-all
|
|
29
|
+
properties:
|
|
30
|
+
call:
|
|
31
|
+
not:
|
|
32
|
+
enum: ["asyncapi", "grpc", "http", "openapi", "a2a", "mcp"]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The `CallFunction` variant uses `not: { enum: [...] }` where the enum values **exactly match** the const values of other variants. This is semantically a discriminated union with a default case.
|
|
36
|
+
|
|
37
|
+
### Current Output
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
z.union([
|
|
41
|
+
asyncApiSchema,
|
|
42
|
+
grpcSchema,
|
|
43
|
+
httpSchema,
|
|
44
|
+
openapiSchema,
|
|
45
|
+
a2aSchema,
|
|
46
|
+
mcpSchema,
|
|
47
|
+
callFunctionSchema // Default case
|
|
48
|
+
])
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This uses O(n) sequential matching - Zod tries each schema until one passes.
|
|
52
|
+
|
|
53
|
+
### Proposed Output
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
z.union([
|
|
57
|
+
z.discriminatedUnion("call", [
|
|
58
|
+
asyncApiSchema,
|
|
59
|
+
grpcSchema,
|
|
60
|
+
httpSchema,
|
|
61
|
+
openapiSchema,
|
|
62
|
+
a2aSchema,
|
|
63
|
+
mcpSchema
|
|
64
|
+
]),
|
|
65
|
+
callFunctionSchema // Default case
|
|
66
|
+
])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Benefits:**
|
|
70
|
+
- Known values (`"asyncapi"`, `"grpc"`, etc.) use O(1) discriminated union lookup
|
|
71
|
+
- Unknown values fail fast from discriminatedUnion, then try the default case
|
|
72
|
+
- More efficient runtime validation for the common case
|
|
73
|
+
|
|
74
|
+
## Detection Algorithm
|
|
75
|
+
|
|
76
|
+
### Step 1: Identify Discriminator Candidates
|
|
77
|
+
|
|
78
|
+
For each property key that appears in all oneOf options:
|
|
79
|
+
1. Collect options where the property has a constant value (`const` or `enum: [single]`)
|
|
80
|
+
2. Identify if exactly ONE option has `not: { enum: [...] }` for the same property
|
|
81
|
+
|
|
82
|
+
### Step 2: Validate Default Case Pattern
|
|
83
|
+
|
|
84
|
+
The "default case" pattern is valid when:
|
|
85
|
+
1. All other options have constant discriminator values
|
|
86
|
+
2. Exactly one option has `not: { enum: values }`
|
|
87
|
+
3. The `values` in the negated enum **exactly match** the const values from other options
|
|
88
|
+
4. The discriminator property is required in all options
|
|
89
|
+
|
|
90
|
+
### Step 3: Generate Optimized Output
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
type DiscriminatorResult =
|
|
94
|
+
| { type: 'full'; key: string }
|
|
95
|
+
| { type: 'withDefault'; key: string; defaultIndex: number; constValues: string[] }
|
|
96
|
+
| undefined;
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Implementation
|
|
100
|
+
|
|
101
|
+
### Changes to `parseOneOf.ts`
|
|
102
|
+
|
|
103
|
+
1. **Enhance `findImplicitDiscriminator`** to detect the default case pattern:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
const findImplicitDiscriminator = (
|
|
107
|
+
options: JsonSchema[],
|
|
108
|
+
refs: Refs
|
|
109
|
+
): DiscriminatorResult => {
|
|
110
|
+
// ... existing logic to collect properties and required fields
|
|
111
|
+
|
|
112
|
+
for (const key of candidateKeys) {
|
|
113
|
+
const constValues: string[] = [];
|
|
114
|
+
let defaultIndex: number | undefined;
|
|
115
|
+
let defaultEnumValues: string[] | undefined;
|
|
116
|
+
|
|
117
|
+
for (let i = 0; i < resolvedOptions.length; i++) {
|
|
118
|
+
const prop = resolvedOptions[i].properties[key];
|
|
119
|
+
|
|
120
|
+
if (prop.const) {
|
|
121
|
+
constValues.push(prop.const);
|
|
122
|
+
} else if (prop.not?.enum) {
|
|
123
|
+
// Potential default case
|
|
124
|
+
if (defaultIndex !== undefined) {
|
|
125
|
+
// Multiple defaults - can't optimize
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
defaultIndex = i;
|
|
129
|
+
defaultEnumValues = prop.not.enum;
|
|
130
|
+
} else {
|
|
131
|
+
// Neither const nor not.enum - can't use discriminated union
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Check if default enum matches const values
|
|
137
|
+
if (defaultIndex !== undefined && defaultEnumValues) {
|
|
138
|
+
const constSet = new Set(constValues);
|
|
139
|
+
const enumSet = new Set(defaultEnumValues);
|
|
140
|
+
if (setsEqual(constSet, enumSet)) {
|
|
141
|
+
return { type: 'withDefault', key, defaultIndex, constValues };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// All have const values
|
|
146
|
+
if (constValues.length === resolvedOptions.length) {
|
|
147
|
+
return { type: 'full', key };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return undefined;
|
|
152
|
+
};
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
2. **Update `parseOneOf`** to handle the `withDefault` case:
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
export const parseOneOf = (schema, refs) => {
|
|
159
|
+
const discriminator = findImplicitDiscriminator(schema.oneOf, refs);
|
|
160
|
+
|
|
161
|
+
if (discriminator?.type === 'withDefault') {
|
|
162
|
+
const { key, defaultIndex, constValues } = discriminator;
|
|
163
|
+
|
|
164
|
+
// Parse all options
|
|
165
|
+
const allParsed = schema.oneOf.map((s, i) => parseSchema(s, {...}));
|
|
166
|
+
|
|
167
|
+
// Separate known variants from default
|
|
168
|
+
const knownVariants = allParsed.filter((_, i) => i !== defaultIndex);
|
|
169
|
+
const defaultVariant = allParsed[defaultIndex];
|
|
170
|
+
|
|
171
|
+
// Generate discriminated union with default fallback
|
|
172
|
+
const discriminatedExpr = `z.discriminatedUnion("${key}", [${knownVariants.map(v => v.expression).join(", ")}])`;
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
expression: `z.union([${discriminatedExpr}, ${defaultVariant.expression}])`,
|
|
176
|
+
type: `z.ZodUnion<readonly [z.ZodDiscriminatedUnion<"${key}", readonly [${knownVariants.map(v => v.type).join(", ")}]>, ${defaultVariant.type}]>`
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ... existing logic for full discriminated union or regular union
|
|
181
|
+
};
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Zod v4 Type System Limitation
|
|
185
|
+
|
|
186
|
+
**Problem**: In Zod v4, `ZodDiscriminatedUnion` cannot be used as a member of `ZodUnion` at the type level.
|
|
187
|
+
|
|
188
|
+
When we generate:
|
|
189
|
+
```typescript
|
|
190
|
+
z.union([
|
|
191
|
+
z.discriminatedUnion("call", [known1, known2, ...]),
|
|
192
|
+
defaultVariant
|
|
193
|
+
])
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The runtime works correctly, but TypeScript fails with:
|
|
197
|
+
```
|
|
198
|
+
Type 'ZodDiscriminatedUnion<...>' is not assignable to type 'SomeType'.
|
|
199
|
+
The types of '_zod.values' are incompatible between these types.
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**Root Cause**: Zod v4's internal `SomeType` constraint doesn't include `ZodDiscriminatedUnion`. This appears to be an intentional design decision, as discriminated unions are meant to be "leaf" schemas, not composable with regular unions.
|
|
203
|
+
|
|
204
|
+
**Workaround Attempted**: Use a type annotation listing all individual variants while keeping the optimized runtime expression. This fails because Zod v4's strict tuple checking requires the type annotation tuple length to match the runtime tuple length.
|
|
205
|
+
|
|
206
|
+
**Potential Solutions**:
|
|
207
|
+
1. **Wait for Zod v4 update**: If Zod adds `ZodDiscriminatedUnion` to `SomeType`, this would work
|
|
208
|
+
2. **Use type assertion**: Cast the result with `as unknown as ZodUnion<...>` - unsafe but functional
|
|
209
|
+
3. **Request Zod feature**: Open an issue requesting discriminated union composability
|
|
210
|
+
|
|
211
|
+
For now, we fall back to regular `z.union()` for schemas with a default case.
|
|
212
|
+
|
|
213
|
+
## Edge Cases
|
|
214
|
+
|
|
215
|
+
1. **Multiple default cases**: If more than one option has `not: { enum }`, fall back to regular union
|
|
216
|
+
2. **Partial enum match**: If `not: { enum }` doesn't exactly match other const values, fall back to regular union
|
|
217
|
+
3. **Non-string discriminators**: Only string values are supported (same as current discriminated union)
|
|
218
|
+
4. **Nested allOf**: Must resolve properties from allOf members (already implemented)
|
|
219
|
+
|
|
220
|
+
## Type Safety
|
|
221
|
+
|
|
222
|
+
The generated type correctly represents the union structure:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
z.ZodUnion<readonly [
|
|
226
|
+
z.ZodDiscriminatedUnion<"call", readonly [
|
|
227
|
+
z.ZodIntersection<typeof TaskBase, z.ZodObject<{call: z.ZodLiteral<"asyncapi">, ...}>>,
|
|
228
|
+
z.ZodIntersection<typeof TaskBase, z.ZodObject<{call: z.ZodLiteral<"grpc">, ...}>>,
|
|
229
|
+
// ... other known variants
|
|
230
|
+
]>,
|
|
231
|
+
z.ZodIntersection<typeof TaskBase, z.ZodObject<{call: z.ZodAny, ...}>> // Default
|
|
232
|
+
]>
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Testing
|
|
236
|
+
|
|
237
|
+
Add test cases for:
|
|
238
|
+
1. Basic discriminated union with default case
|
|
239
|
+
2. Default case with exact enum match
|
|
240
|
+
3. Default case with partial enum match (should fall back to union)
|
|
241
|
+
4. Multiple potential defaults (should fall back to union)
|
|
242
|
+
5. Real-world workflow spec CallTask schema
|
|
243
|
+
|
|
244
|
+
## Future Considerations
|
|
245
|
+
|
|
246
|
+
- Could extend to support multiple "catch-all" patterns beyond `not: { enum }`
|
|
247
|
+
- Could support numeric discriminators if needed
|
|
248
|
+
- Could potentially use Zod's `.catch()` for even more efficient default handling
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Inline Object Lifting (Top-Level Reusable Types)
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
Lift inline, non-cyclic object schemas into top-level `$defs` so both bundle and single-file outputs emit reusable Zod schemas (e.g., CallTask `with` objects such as AsyncAPI become first-class exports). This is now the default behavior (opt-out via a flag). Move lifting into an IR transformation pipeline (not just a raw-schema pre-pass) for stronger guarantees and shared behavior.
|
|
5
|
+
|
|
6
|
+
## Scope
|
|
7
|
+
- Applies to both `jsonSchemaToZod` (single file) and `generateSchemaBundle` (multi-file).
|
|
8
|
+
- Targets inline object-like schemas (properties/patternProperties/additionalProperties/items/allOf/anyOf/oneOf/if/then/else/dependentSchemas/contains/not).
|
|
9
|
+
- Skip cyclic/self-referential candidates or ones where lifting would change semantics; err on not lifting when uncertain.
|
|
10
|
+
|
|
11
|
+
## High-Level Flow (IR-centric)
|
|
12
|
+
1) **Analyze to IR**: ingest JSON Schema → IR with refs, dependencies, SCCs, and registry (as per bundle refactor proposal).
|
|
13
|
+
2) **Hoist transform (IR pass)**:
|
|
14
|
+
- Detect inline object-like nodes (properties/items/allOf/anyOf/oneOf/if/then/else/dependentSchemas/contains/not, etc.).
|
|
15
|
+
- Skip boolean schemas, `$ref`/`$dynamicRef`, and `$defs` members.
|
|
16
|
+
- Use IR dependency graph/ref registry for accurate ancestor/self cycle detection; if ambiguous, skip.
|
|
17
|
+
- Optionally deduplicate by structural hash (excluding titles/descriptions) so identical shapes share one hoisted def.
|
|
18
|
+
3) **Name** via a centralized naming service:
|
|
19
|
+
- Base on nearest named parent (root/def) + path; include discriminator/const-enum hints; suffix on collisions.
|
|
20
|
+
- Allow `nameForPath` hook; all passes/emitters call the same service.
|
|
21
|
+
4) **Rewrite IR**:
|
|
22
|
+
- Create top-level def nodes for hoisted shapes; replace inline nodes with ref nodes.
|
|
23
|
+
- Preserve annotations; keep ASCII identifiers.
|
|
24
|
+
- Emit debug metadata (path → def name) for tests/telemetry.
|
|
25
|
+
5) **Emit**:
|
|
26
|
+
- Single-file: emit Zod using transformed IR.
|
|
27
|
+
- Bundle: build defInfoMap/plan targets/imports from transformed IR; lifted defs flow like native `$defs`.
|
|
28
|
+
|
|
29
|
+
## Options
|
|
30
|
+
- Extend `Options` and `GenerateBundleOptions` with:
|
|
31
|
+
- `liftInlineObjects?: { enable?: boolean; nameForPath?: (path, ctx) => string }`
|
|
32
|
+
- Default: `enable` is true; set `enable: false` to opt out.
|
|
33
|
+
- Use `name`/`rootName` as the base parent name; fallback to `Root` when absent.
|
|
34
|
+
|
|
35
|
+
## Integration Points
|
|
36
|
+
- **Shared IR pass**: integrate the hoist transform into the IR pipeline used by both `jsonSchemaToZod` and `generateSchemaBundle`.
|
|
37
|
+
- **Single file**: run analysis → hoist pass → emit.
|
|
38
|
+
- **Bundle**: run analysis → hoist pass → plan/emit; new defs appear in defInfoMap/imports.
|
|
39
|
+
- **Nested types**: lifted items are no longer “nested” (expected when the flag is on).
|
|
40
|
+
|
|
41
|
+
## Naming Strategy (default)
|
|
42
|
+
- Centralized naming service (IR utility) used by all passes/emitters.
|
|
43
|
+
- Base: nearest named ancestor (def name → PascalCase; root → `Root` or provided `name`/`rootName`).
|
|
44
|
+
- Path segments: PascalCase property names; union branches include discriminator const/enum when present; else index (`Option1`).
|
|
45
|
+
- Collision resolution: set of existing def names + assigned names; append numeric suffix.
|
|
46
|
+
- Hook: `nameForPath(path, { parentName, branchInfo, existingNames })`.
|
|
47
|
+
|
|
48
|
+
## Safety / Skip Rules
|
|
49
|
+
- Skip boolean schemas, pure meta-only objects (no constraints), ambiguous `unevaluatedProperties` contexts where lifting could alter validation, and any detected cycles.
|
|
50
|
+
- Use ref registry/IR deps for cycle detection; if unclear, do not lift.
|
|
51
|
+
- Structural hash dedup optional: only if shapes match; otherwise, keep distinct.
|
|
52
|
+
|
|
53
|
+
## Testing Plan
|
|
54
|
+
- **Unit (hoist IR pass)**:
|
|
55
|
+
- Lifts simple inline property object → top-level def + ref node.
|
|
56
|
+
- Lifts inside allOf/oneOf/anyOf branches; names stable and unique.
|
|
57
|
+
- Handles items/additionalProperties/patternProperties.
|
|
58
|
+
- Skips self/ancestor-ref cycles (using ref registry).
|
|
59
|
+
- Collision handling and custom `nameForPath` hook.
|
|
60
|
+
- Optional structural hash dedup: identical shapes hoisted once.
|
|
61
|
+
- **Integration**:
|
|
62
|
+
- Default-on: workflow fixture shows CallTask `with` shapes as top-level defs; CallTask branches reference them; `pnpm test` + workflow snapshot pass.
|
|
63
|
+
- Opt-out coverage: with `enable: false`, outputs match legacy layout (no lifting) to preserve backward compatibility when explicitly requested.
|
|
64
|
+
- Maintain snapshots for both modes to contain churn while default-on is adopted.
|
|
65
|
+
|
|
66
|
+
## Risks / Mitigations
|
|
67
|
+
- **Semantics drift**: lifting could change validation if sibling keywords depend on inline position (e.g., `unevaluatedProperties`, composition). Mitigate with conservative skip logic; if context is ambiguous, do not lift. Default-on but easily opt-out with `enable: false`.
|
|
68
|
+
- **Cycle misdetection**: identity-only checks can miss `$ref`/`$dynamicRef` loops. Mitigate by reusing ref registry/IR deps for ancestor/self detection in the hoist pass.
|
|
69
|
+
- **Naming collisions**: new defs could clash with existing `$defs` or generated names. Mitigate with centralized naming service, suffixing, optional hook, and checks against defInfoMap inputs.
|
|
70
|
+
- **Bundle import gaps**: lifted defs must flow into defInfoMap/planBundleTargets. Mitigate by running the hoist pass before planning so new defNames/imports are included.
|
|
71
|
+
- **Single/bundle divergence**: if only bundle is wired, single-file outputs diverge. Mitigate by invoking the same IR hoist pass in `jsonSchemaToZod` and exposing the flag in shared `Options`.
|
|
72
|
+
|
|
73
|
+
## Implementation Notes
|
|
74
|
+
- New IR transform: `src/utils/liftInlineObjects.ts` (pure, no side effects) operating on IR nodes rather than raw schema where possible; if raw is needed, still leverage ref registry.
|
|
75
|
+
- Returns `{ rootSchema, defs, addedDefNames, pathToDefName }` (or IR equivalents) for debugging/tests.
|
|
76
|
+
- Central naming service (shared util) used by hoist, emitters, and other passes; reuse `toPascalCase` and keep ASCII identifiers.
|
|
77
|
+
- Reuse traversal coverage from `findNestedTypesInSchema` to avoid missing positions; prefer IR graph traversal for accuracy.
|
package/eslint.config.js
CHANGED
|
@@ -3,7 +3,7 @@ import pluginTs from "@typescript-eslint/eslint-plugin";
|
|
|
3
3
|
|
|
4
4
|
export default [
|
|
5
5
|
{
|
|
6
|
-
ignores: ["dist", "node_modules", "test/output/**"],
|
|
6
|
+
ignores: ["dist", "node_modules", "test/output/**", "*.config.js", "*.config.cjs", ".tmp-*"],
|
|
7
7
|
},
|
|
8
8
|
{
|
|
9
9
|
files: ["**/*.ts", "**/*.tsx"],
|
|
@@ -18,7 +18,9 @@ export default [
|
|
|
18
18
|
"@typescript-eslint": pluginTs,
|
|
19
19
|
},
|
|
20
20
|
rules: {
|
|
21
|
-
|
|
21
|
+
// Use only non-type-aware rules from recommended
|
|
22
|
+
"@typescript-eslint/no-unused-vars": "error",
|
|
23
|
+
"@typescript-eslint/no-explicit-any": "warn",
|
|
22
24
|
"@typescript-eslint/no-require-imports": "error",
|
|
23
25
|
"@typescript-eslint/no-var-requires": "error",
|
|
24
26
|
},
|
package/jest.config.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** @type {import('@jest/types').Config.InitialOptions} */
|
|
2
|
+
export default {
|
|
3
|
+
testEnvironment: "node",
|
|
4
|
+
testMatch: ["**/test/**/*.test.ts"],
|
|
5
|
+
extensionsToTreatAsEsm: [".ts"],
|
|
6
|
+
transform: {
|
|
7
|
+
"^.+\\.tsx?$": [
|
|
8
|
+
"ts-jest",
|
|
9
|
+
{
|
|
10
|
+
useESM: true,
|
|
11
|
+
tsconfig: "tsconfig.jest.json",
|
|
12
|
+
diagnostics: false,
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
moduleNameMapper: {
|
|
17
|
+
"^(\\.{1,2}/.*)\\.js$": "$1",
|
|
18
|
+
},
|
|
19
|
+
};
|
package/package.json
CHANGED
|
@@ -1,29 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gabrielbryk/json-schema-to-zod",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"description": "Converts JSON schema objects or files into Zod schemas",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./dist/types/index.d.ts",
|
|
7
|
-
"bin": "./dist/
|
|
8
|
-
"main": "./dist/
|
|
9
|
-
"module": "./dist/
|
|
7
|
+
"bin": "./dist/cli.js",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
10
|
"exports": {
|
|
11
|
-
"
|
|
11
|
+
".": {
|
|
12
12
|
"types": "./dist/types/index.d.ts",
|
|
13
|
-
"default": "./dist/
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
14
|
},
|
|
15
|
-
"
|
|
16
|
-
"types": "./dist/types/index.d.ts",
|
|
17
|
-
"default": "./dist/cjs/index.js"
|
|
18
|
-
}
|
|
15
|
+
"./package.json": "./package.json"
|
|
19
16
|
},
|
|
20
17
|
"c8": {
|
|
21
18
|
"exclude": [
|
|
22
19
|
"dist",
|
|
23
20
|
"createIndex.ts",
|
|
24
21
|
"jest.config.js",
|
|
25
|
-
"
|
|
26
|
-
"
|
|
22
|
+
"jest.config.ts",
|
|
23
|
+
"jest.config.mjs",
|
|
27
24
|
"test"
|
|
28
25
|
]
|
|
29
26
|
},
|
|
@@ -66,25 +63,25 @@
|
|
|
66
63
|
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
|
67
64
|
"@typescript-eslint/parser": "^8.49.0",
|
|
68
65
|
"@types/json-schema": "^7.0.15",
|
|
66
|
+
"@types/jest": "^29.5.14",
|
|
69
67
|
"@types/node": "^20.9.0",
|
|
70
|
-
"fast-diff": "^1.3.0",
|
|
71
68
|
"eslint": "^9.39.1",
|
|
69
|
+
"jest": "^29.7.0",
|
|
72
70
|
"js-yaml": "^4.1.0",
|
|
73
71
|
"rimraf": "^5.0.5",
|
|
72
|
+
"ts-jest": "^29.3.4",
|
|
74
73
|
"tsx": "^4.1.1",
|
|
75
74
|
"typescript": "^5.2.2",
|
|
76
75
|
"zod": "^4.1.13"
|
|
77
76
|
},
|
|
78
77
|
"scripts": {
|
|
79
|
-
"build:
|
|
80
|
-
"build
|
|
81
|
-
"build:esm": "tsc -p tsconfig.esm.json && node postesm.cjs",
|
|
82
|
-
"build": "pnpm gen && pnpm test && rimraf ./dist && pnpm build:types && pnpm build:cjs && pnpm build:esm",
|
|
78
|
+
"build:esm": "tsc -p tsconfig.build.json",
|
|
79
|
+
"build": "pnpm gen && pnpm test && rimraf ./dist && pnpm build:esm",
|
|
83
80
|
"dry": "pnpm build && pnpm publish --dry-run",
|
|
84
|
-
"dev": "
|
|
81
|
+
"dev": "NODE_OPTIONS=--experimental-vm-modules jest --watch",
|
|
85
82
|
"gen": "tsx ./createIndex.ts",
|
|
86
|
-
"test": "
|
|
83
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest",
|
|
87
84
|
"lint": "eslint \"src/**/*.{ts,tsx}\" \"test/**/*.ts\"",
|
|
88
|
-
"smoke:esm": "pnpm build:esm && node --input-type=module -e \"import { jsonSchemaToZod } from './dist/
|
|
85
|
+
"smoke:esm": "pnpm build:esm && node --input-type=module -e \"import { jsonSchemaToZod } from './dist/index.js'; console.log(jsonSchemaToZod({type:'string'}));\""
|
|
89
86
|
}
|
|
90
87
|
}
|
package/dist/cjs/Types.js
DELETED
package/dist/cjs/cli.js
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
const jsonSchemaToZod_js_1 = require("./jsonSchemaToZod.js");
|
|
5
|
-
const fs_1 = require("fs");
|
|
6
|
-
const path_1 = require("path");
|
|
7
|
-
const cliTools_js_1 = require("./utils/cliTools.js");
|
|
8
|
-
const params = {
|
|
9
|
-
input: {
|
|
10
|
-
shorthand: "i",
|
|
11
|
-
value: "string",
|
|
12
|
-
required: process.stdin.isTTY &&
|
|
13
|
-
"input is required when no JSON or file path is piped",
|
|
14
|
-
description: "JSON or a source file path. Required if no data is piped.",
|
|
15
|
-
},
|
|
16
|
-
output: {
|
|
17
|
-
shorthand: "o",
|
|
18
|
-
value: "string",
|
|
19
|
-
description: "A file path to write to. If not supplied stdout will be used.",
|
|
20
|
-
},
|
|
21
|
-
name: {
|
|
22
|
-
shorthand: "n",
|
|
23
|
-
value: "string",
|
|
24
|
-
description: "The name of the schema in the output.",
|
|
25
|
-
},
|
|
26
|
-
depth: {
|
|
27
|
-
shorthand: "d",
|
|
28
|
-
value: "number",
|
|
29
|
-
description: "Maximum depth of recursion before falling back to z.any(). Defaults to 0.",
|
|
30
|
-
},
|
|
31
|
-
module: {
|
|
32
|
-
shorthand: "m",
|
|
33
|
-
value: ["esm", "cjs", "none"],
|
|
34
|
-
description: "Module syntax; 'esm', 'cjs' or 'none'. Defaults to 'esm'.",
|
|
35
|
-
},
|
|
36
|
-
type: {
|
|
37
|
-
shorthand: "t",
|
|
38
|
-
value: "string",
|
|
39
|
-
description: "The name of the (optional) inferred type export."
|
|
40
|
-
},
|
|
41
|
-
noImport: {
|
|
42
|
-
shorthand: "ni",
|
|
43
|
-
description: "Removes the `import { z } from 'zod';` or equivalent from the output."
|
|
44
|
-
},
|
|
45
|
-
withJsdocs: {
|
|
46
|
-
shorthand: "wj",
|
|
47
|
-
description: "Generate jsdocs off of the description property.",
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
async function main() {
|
|
51
|
-
const args = (0, cliTools_js_1.parseArgs)(params, process.argv, true);
|
|
52
|
-
const input = args.input || (await (0, cliTools_js_1.readPipe)());
|
|
53
|
-
const jsonSchema = (0, cliTools_js_1.parseOrReadJSON)(input);
|
|
54
|
-
const zodSchema = (0, jsonSchemaToZod_js_1.jsonSchemaToZod)(jsonSchema, {
|
|
55
|
-
name: args.name,
|
|
56
|
-
depth: args.depth,
|
|
57
|
-
module: args.module || "esm",
|
|
58
|
-
noImport: args.noImport,
|
|
59
|
-
type: args.type,
|
|
60
|
-
withJsdocs: args.withJsdocs,
|
|
61
|
-
});
|
|
62
|
-
if (args.output) {
|
|
63
|
-
(0, fs_1.mkdirSync)((0, path_1.dirname)(args.output), { recursive: true });
|
|
64
|
-
(0, fs_1.writeFileSync)(args.output, zodSchema);
|
|
65
|
-
}
|
|
66
|
-
else {
|
|
67
|
-
console.log(zodSchema);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
void main();
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.analyzeSchema = void 0;
|
|
4
|
-
const parseSchema_js_1 = require("../parsers/parseSchema.js");
|
|
5
|
-
const cycles_js_1 = require("../utils/cycles.js");
|
|
6
|
-
const buildRefRegistry_js_1 = require("../utils/buildRefRegistry.js");
|
|
7
|
-
const analyzeSchema = (schema, options = {}) => {
|
|
8
|
-
const { module, name, type, ...rest } = options;
|
|
9
|
-
if (type && (!name || module !== "esm")) {
|
|
10
|
-
throw new Error("Option `type` requires `name` to be set and `module` to be `esm`");
|
|
11
|
-
}
|
|
12
|
-
const normalized = {
|
|
13
|
-
module,
|
|
14
|
-
name,
|
|
15
|
-
type,
|
|
16
|
-
...rest,
|
|
17
|
-
exportRefs: rest.exportRefs ?? true,
|
|
18
|
-
withMeta: rest.withMeta ?? true,
|
|
19
|
-
};
|
|
20
|
-
const refNameByPointer = new Map();
|
|
21
|
-
const usedNames = new Set();
|
|
22
|
-
if (name) {
|
|
23
|
-
usedNames.add(name);
|
|
24
|
-
}
|
|
25
|
-
const declarations = new Map();
|
|
26
|
-
const dependencies = new Map();
|
|
27
|
-
const { registry: refRegistry, rootBaseUri } = (0, buildRefRegistry_js_1.buildRefRegistry)(schema);
|
|
28
|
-
const pass1 = {
|
|
29
|
-
module,
|
|
30
|
-
name,
|
|
31
|
-
path: [],
|
|
32
|
-
seen: new Map(),
|
|
33
|
-
declarations,
|
|
34
|
-
dependencies,
|
|
35
|
-
inProgress: new Set(),
|
|
36
|
-
refNameByPointer,
|
|
37
|
-
usedNames,
|
|
38
|
-
root: schema,
|
|
39
|
-
currentSchemaName: name,
|
|
40
|
-
refRegistry,
|
|
41
|
-
rootBaseUri,
|
|
42
|
-
...rest,
|
|
43
|
-
withMeta: normalized.withMeta,
|
|
44
|
-
};
|
|
45
|
-
(0, parseSchema_js_1.parseSchema)(schema, pass1);
|
|
46
|
-
const names = Array.from(declarations.keys());
|
|
47
|
-
const cycleRefNames = (0, cycles_js_1.detectCycles)(names, dependencies);
|
|
48
|
-
const { componentByName } = (0, cycles_js_1.computeScc)(names, dependencies);
|
|
49
|
-
return {
|
|
50
|
-
schema,
|
|
51
|
-
options: normalized,
|
|
52
|
-
refNameByPointer,
|
|
53
|
-
usedNames,
|
|
54
|
-
declarations,
|
|
55
|
-
dependencies,
|
|
56
|
-
cycleRefNames,
|
|
57
|
-
cycleComponentByName: componentByName,
|
|
58
|
-
refRegistry,
|
|
59
|
-
rootBaseUri,
|
|
60
|
-
};
|
|
61
|
-
};
|
|
62
|
-
exports.analyzeSchema = analyzeSchema;
|