Skip to content

Commit 572d3a6

Browse files
authored
Merge pull request #32 from code0-tech/feat/recursive-type-schemas
Fix recursive type schemas
2 parents 99702c7 + b659113 commit 572d3a6

3 files changed

Lines changed: 108 additions & 4 deletions

File tree

ts/src/internal/zod-schema.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,34 @@ function buildOverrides(skip: ZodTypeAny): TypeOverrideMap {
3030
}
3131

3232
export function zodToTypeString(schema: ZodTypeAny): string {
33+
const selfIdentifier = schemaRegistry.get(schema);
34+
const auxiliaryTypeStore = createAuxiliaryTypeStore();
35+
let rootVisited = false;
3336
const {node} = zodToTs(schema, {
34-
auxiliaryTypeStore: createAuxiliaryTypeStore(),
37+
auxiliaryTypeStore,
3538
overrides: buildOverrides(schema),
39+
overrideFunction: (candidate, typescript) => {
40+
if (candidate !== schema || selfIdentifier === undefined) return undefined;
41+
if (!rootVisited) {
42+
rootVisited = true;
43+
return undefined;
44+
}
45+
return typescript.factory.createTypeReferenceNode(typescript.factory.createIdentifier(selfIdentifier));
46+
},
3647
});
37-
return printNode(node, {removeComments: true, omitTrailingSemicolon: true}).replace(/\s+/g, " ").trim();
48+
const type = printNode(node, {removeComments: true, omitTrailingSemicolon: true}).replace(/\s+/g, " ").trim();
49+
if (auxiliaryTypeStore.definitions.size > 0) {
50+
const subject = selfIdentifier ? `data type "${selfIdentifier}"` : "the schema";
51+
throw new Error(
52+
`Cannot generate a type string for ${subject}: it contains recursive schemas that cannot be inlined. ` +
53+
`Register each recursive schema as its own data type so it can be referenced by identifier.`
54+
);
55+
}
56+
return type;
3857
}
3958

4059
export function zodToRules(schema: ZodTypeAny): DefinitionDataTypeRule[] {
41-
const json = toJSONSchema(schema) as JsonSchema;
60+
const json = toJSONSchema(schema, {cycles: "ref"}) as JsonSchema;
4261
const rules: DefinitionDataTypeRule[] = [];
4362
if (json.pattern) {
4463
rules.push({config: {oneofKind: "regex", regex: {pattern: json.pattern}}});

ts/src/map/datatype.map.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ export const dataTypeMap = (klass: DataTypeClass): DataTypeProps => {
1818

1919
return {
2020
identifier,
21-
type: zodToTypeString(schema),
21+
get type() {
22+
return zodToTypeString(schema)
23+
},
2224
rules: zodToRules(schema),
2325
name,
2426
displayMessage,

ts/test/datatype.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import "reflect-metadata";
2+
import { describe, expect, it } from "vitest";
3+
import { z, type ZodTypeAny } from "zod";
4+
import { Identifier } from "../src/decorators/meta.dec";
5+
import { Schema } from "../src/decorators/datatype.dec";
6+
import { dataTypeMap } from "../src/map/datatype.map";
7+
8+
describe("dataTypeMap type generation", () => {
9+
it("inlines plain schemas", () => {
10+
@Identifier("PlainType")
11+
@Schema(z.object({ name: z.string() }))
12+
class PlainType {}
13+
14+
expect(dataTypeMap(PlainType).type).toBe("{ name: string; }");
15+
});
16+
17+
it("resolves mutually recursive schemas by identifier regardless of registration order", () => {
18+
const OrderSchema: ZodTypeAny = z.lazy(() =>
19+
z.object({ addresses: z.array(AddressSchema) })
20+
);
21+
const AddressSchema: ZodTypeAny = z.lazy(() =>
22+
z.object({ order: OrderSchema })
23+
);
24+
25+
@Identifier("MutualOrder")
26+
@Schema(OrderSchema)
27+
class MutualOrder {}
28+
29+
@Identifier("MutualAddress")
30+
@Schema(AddressSchema)
31+
class MutualAddress {}
32+
33+
const orderDef = dataTypeMap(MutualOrder);
34+
const addressDef = dataTypeMap(MutualAddress);
35+
36+
expect(orderDef.type).toBe("{ addresses: MutualAddress[]; }");
37+
expect(addressDef.type).toBe("{ order: MutualOrder; }");
38+
});
39+
40+
it("references itself by identifier for self-recursive schemas", () => {
41+
const NodeSchema: ZodTypeAny = z.lazy(() =>
42+
z.object({ children: z.array(NodeSchema) })
43+
);
44+
45+
@Identifier("TreeNode")
46+
@Schema(NodeSchema)
47+
class TreeNode {}
48+
49+
expect(dataTypeMap(TreeNode).type).toBe("{ children: TreeNode[]; }");
50+
});
51+
52+
it("prints registered schemas embedded in other types as identifier references", () => {
53+
const ItemSchema: ZodTypeAny = z.lazy(() =>
54+
z.object({ related: z.array(ItemSchema) })
55+
);
56+
57+
@Identifier("EmbeddedItem")
58+
@Schema(ItemSchema)
59+
class EmbeddedItem {}
60+
61+
@Identifier("ItemPayload")
62+
@Schema(z.object({ item: ItemSchema, count: z.number() }))
63+
class ItemPayload {}
64+
65+
dataTypeMap(EmbeddedItem);
66+
const payloadDef = dataTypeMap(ItemPayload);
67+
68+
expect(payloadDef.type).toBe("{ item: EmbeddedItem; count: number; }");
69+
});
70+
71+
it("throws a helpful error when a recursive schema is not registered as a data type", () => {
72+
const LoopSchema: ZodTypeAny = z.lazy(() =>
73+
z.object({ next: LoopSchema })
74+
);
75+
76+
@Identifier("BrokenPayload")
77+
@Schema(z.object({ loop: LoopSchema }))
78+
class BrokenPayload {}
79+
80+
const def = dataTypeMap(BrokenPayload);
81+
expect(() => def.type).toThrow(/BrokenPayload.*recursive/i);
82+
});
83+
});

0 commit comments

Comments
 (0)