zod-codegen 1.4.1 → 1.5.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.
@@ -107,7 +107,7 @@ jobs:
107
107
  test -e "generated/type.ts"
108
108
 
109
109
  - name: Upload build artifacts
110
- uses: actions/upload-artifact@v4
110
+ uses: actions/upload-artifact@v6
111
111
  with:
112
112
  name: build-${{ github.sha }}
113
113
  path: dist/
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## 1.5.0 (2025-12-22)
2
+
3
+ - Merge pull request #44 from julienandreu/dependabot/github_actions/actions/upload-artifact-6 ([cebfd55](https://github.com/julienandreu/zod-codegen/commit/cebfd55)), closes [#44](https://github.com/julienandreu/zod-codegen/issues/44)
4
+ - Merge pull request #45 from julienandreu/dependabot/npm_and_yarn/dev-dependencies-ab633d47b1 ([8663f40](https://github.com/julienandreu/zod-codegen/commit/8663f40)), closes [#45](https://github.com/julienandreu/zod-codegen/issues/45)
5
+ - Merge pull request #46 from julienandreu/dependabot/npm_and_yarn/production-dependencies-eefc12583a ([eac5279](https://github.com/julienandreu/zod-codegen/commit/eac5279)), closes [#46](https://github.com/julienandreu/zod-codegen/issues/46)
6
+ - Merge pull request #47 from julienandreu/dependabot/npm_and_yarn/types/node-25.0.2 ([687ce64](https://github.com/julienandreu/zod-codegen/commit/687ce64)), closes [#47](https://github.com/julienandreu/zod-codegen/issues/47)
7
+ - Merge pull request #49 from julienandreu/dependabot/npm_and_yarn/production-dependencies-5419ff3310 ([1d6485c](https://github.com/julienandreu/zod-codegen/commit/1d6485c)), closes [#49](https://github.com/julienandreu/zod-codegen/issues/49)
8
+ - Merge pull request #50 from julienandreu/feat/circular-dependency-detection ([527cbb6](https://github.com/julienandreu/zod-codegen/commit/527cbb6)), closes [#50](https://github.com/julienandreu/zod-codegen/issues/50)
9
+ - feat: detect circular dependencies and wrap with z.lazy() ([4f5f545](https://github.com/julienandreu/zod-codegen/commit/4f5f545))
10
+ - chore(deps-dev): bump @types/node from 24.10.1 to 25.0.2 ([da9bbc7](https://github.com/julienandreu/zod-codegen/commit/da9bbc7))
11
+ - chore(deps-dev): bump the dev-dependencies group with 2 updates ([29ad3ef](https://github.com/julienandreu/zod-codegen/commit/29ad3ef))
12
+ - chore(deps): bump zod in the production-dependencies group ([391d6ea](https://github.com/julienandreu/zod-codegen/commit/391d6ea))
13
+ - chore(deps): bump zod in the production-dependencies group ([41177c8](https://github.com/julienandreu/zod-codegen/commit/41177c8))
14
+ - ci(deps): bump actions/upload-artifact from 4 to 6 ([c19e254](https://github.com/julienandreu/zod-codegen/commit/c19e254))
15
+
1
16
  ## <small>1.4.1 (2025-12-08)</small>
2
17
 
3
18
  - Merge branch 'refactor/build-config-and-class-exports' of github.com:julienandreu/zod-codegen into r ([bdd9bc5](https://github.com/julienandreu/zod-codegen/commit/bdd9bc5))
@@ -8,6 +8,8 @@ export declare class TypeScriptCodeGeneratorService implements CodeGenerator, Sc
8
8
  private readonly printer;
9
9
  private readonly namingConvention;
10
10
  private readonly operationNameTransformer;
11
+ private circularSchemas;
12
+ private currentSchemaName;
11
13
  constructor(options?: GeneratorOptions);
12
14
  private readonly ZodAST;
13
15
  generate(spec: OpenApiSpecType): string;
@@ -52,6 +54,19 @@ export declare class TypeScriptCodeGeneratorService implements CodeGenerator, Sc
52
54
  private buildArrayTypeFromSchema;
53
55
  private isReference;
54
56
  private buildFromReference;
57
+ /**
58
+ * Determines if a reference creates a circular dependency that needs z.lazy().
59
+ * A reference is circular if:
60
+ * 1. It's a direct self-reference (schema references itself)
61
+ * 2. It's part of a circular dependency chain (A -> B -> A)
62
+ */
63
+ private isCircularReference;
64
+ /**
65
+ * Detects schemas that are part of circular dependency chains.
66
+ * Uses a modified Tarjan's algorithm to find strongly connected components (SCCs).
67
+ * Schemas in SCCs with more than one node, or self-referencing schemas, are circular.
68
+ */
69
+ private detectCircularDependencies;
55
70
  private topologicalSort;
56
71
  }
57
72
  //# sourceMappingURL=code-generator.service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"code-generator.service.d.ts","sourceRoot":"","sources":["../../../src/services/code-generator.service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,YAAY,CAAC;AAEjC,OAAO,KAAK,EAAC,aAAa,EAAE,aAAa,EAAC,MAAM,iCAAiC,CAAC;AAClF,OAAO,KAAK,EAAmB,eAAe,EAAgB,MAAM,qBAAqB,CAAC;AAC1F,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAWpE,qBAAa,8BAA+B,YAAW,aAAa,EAAE,aAAa;IACjF,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsC;IAClE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAwC;IACtE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwD;IAChF,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA+B;IAChE,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAuC;gBAEpE,OAAO,GAAE,gBAAqB;IAK1C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAGpB;IAEH,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG,MAAM;IAMvC,WAAW,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,UAAO,GAAG,EAAE,CAAC,cAAc,GAAG,EAAE,CAAC,UAAU;IA2BhF,OAAO,CAAC,QAAQ;IAmBhB,OAAO,CAAC,YAAY;IA8BpB,OAAO,CAAC,sBAAsB;IAe9B,OAAO,CAAC,gBAAgB;IAsBxB,OAAO,CAAC,gBAAgB;IAmGxB,OAAO,CAAC,gCAAgC;IAwBxC,OAAO,CAAC,sBAAsB;IAqmB9B,OAAO,CAAC,kBAAkB;IAsB1B;;;OAGG;IACH,OAAO,CAAC,sBAAsB;IA0B9B,OAAO,CAAC,mBAAmB;IAgH3B,OAAO,CAAC,mBAAmB;IA6D3B,OAAO,CAAC,qBAAqB;IAwF7B,OAAO,CAAC,gBAAgB;IA0CxB,OAAO,CAAC,iBAAiB;IAmCzB,OAAO,CAAC,iBAAiB;IA8BzB,OAAO,CAAC,eAAe;IAuDvB,OAAO,CAAC,wBAAwB;IA4IhC,OAAO,CAAC,gBAAgB;IAqBxB,OAAO,CAAC,6BAA6B;IAkPrC,OAAO,CAAC,kBAAkB;IAO1B,OAAO,CAAC,aAAa;IAMrB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAuFzB,OAAO,CAAC,WAAW;IA0CnB,OAAO,CAAC,aAAa;IAmjBrB,OAAO,CAAC,iBAAiB;IAuCzB,OAAO,CAAC,qBAAqB;IAe7B,OAAO,CAAC,oBAAoB;IAyE5B,OAAO,CAAC,8BAA8B;IActC,OAAO,CAAC,wBAAwB;IAyBhC,OAAO,CAAC,yBAAyB;IA8BjC,OAAO,CAAC,wBAAwB;IAYhC,OAAO,CAAC,WAAW;IAQnB,OAAO,CAAC,kBAAkB;IAM1B,OAAO,CAAC,eAAe;CAuCxB"}
1
+ {"version":3,"file":"code-generator.service.d.ts","sourceRoot":"","sources":["../../../src/services/code-generator.service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,YAAY,CAAC;AAEjC,OAAO,KAAK,EAAC,aAAa,EAAE,aAAa,EAAC,MAAM,iCAAiC,CAAC;AAClF,OAAO,KAAK,EAAmB,eAAe,EAAgB,MAAM,qBAAqB,CAAC;AAC1F,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,+BAA+B,CAAC;AAWpE,qBAAa,8BAA+B,YAAW,aAAa,EAAE,aAAa;IACjF,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsC;IAClE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAwC;IACtE,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwD;IAChF,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA+B;IAChE,OAAO,CAAC,QAAQ,CAAC,wBAAwB,CAAuC;IAGhF,OAAO,CAAC,eAAe,CAAqB;IAC5C,OAAO,CAAC,iBAAiB,CAAuB;gBAEpC,OAAO,GAAE,gBAAqB;IAK1C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAGpB;IAEH,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG,MAAM;IAMvC,WAAW,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,UAAO,GAAG,EAAE,CAAC,cAAc,GAAG,EAAE,CAAC,UAAU;IA2BhF,OAAO,CAAC,QAAQ;IAmBhB,OAAO,CAAC,YAAY;IA2CpB,OAAO,CAAC,sBAAsB;IAe9B,OAAO,CAAC,gBAAgB;IAsBxB,OAAO,CAAC,gBAAgB;IAmGxB,OAAO,CAAC,gCAAgC;IAwBxC,OAAO,CAAC,sBAAsB;IAqmB9B,OAAO,CAAC,kBAAkB;IAsB1B;;;OAGG;IACH,OAAO,CAAC,sBAAsB;IA0B9B,OAAO,CAAC,mBAAmB;IAgH3B,OAAO,CAAC,mBAAmB;IA6D3B,OAAO,CAAC,qBAAqB;IAwF7B,OAAO,CAAC,gBAAgB;IA0CxB,OAAO,CAAC,iBAAiB;IAmCzB,OAAO,CAAC,iBAAiB;IA8BzB,OAAO,CAAC,eAAe;IAuDvB,OAAO,CAAC,wBAAwB;IA4IhC,OAAO,CAAC,gBAAgB;IAqBxB,OAAO,CAAC,6BAA6B;IAkPrC,OAAO,CAAC,kBAAkB;IAO1B,OAAO,CAAC,aAAa;IAMrB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAuFzB,OAAO,CAAC,WAAW;IA0CnB,OAAO,CAAC,aAAa;IAmjBrB,OAAO,CAAC,iBAAiB;IAuCzB,OAAO,CAAC,qBAAqB;IAe7B,OAAO,CAAC,oBAAoB;IAyE5B,OAAO,CAAC,8BAA8B;IActC,OAAO,CAAC,wBAAwB;IAyBhC,OAAO,CAAC,yBAAyB;IA8BjC,OAAO,CAAC,wBAAwB;IAYhC,OAAO,CAAC,WAAW;IAQnB,OAAO,CAAC,kBAAkB;IA8B1B;;;;;OAKG;IACH,OAAO,CAAC,mBAAmB;IAmB3B;;;;OAIG;IACH,OAAO,CAAC,0BAA0B;IAiFlC,OAAO,CAAC,eAAe;CAuCxB"}
@@ -11,6 +11,9 @@ export class TypeScriptCodeGeneratorService {
11
11
  printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
12
12
  namingConvention;
13
13
  operationNameTransformer;
14
+ // Track circular dependencies for z.lazy() wrapping
15
+ circularSchemas = new Set();
16
+ currentSchemaName = null;
14
17
  constructor(options = {}) {
15
18
  this.namingConvention = options.namingConvention;
16
19
  this.operationNameTransformer = options.operationNameTransformer;
@@ -63,13 +66,21 @@ export class TypeScriptCodeGeneratorService {
63
66
  }
64
67
  buildSchemas(openapi) {
65
68
  const schemasEntries = Object.entries(openapi.components?.schemas ?? {});
66
- const sortedSchemaNames = this.topologicalSort(Object.fromEntries(schemasEntries));
69
+ const schemasMap = Object.fromEntries(schemasEntries);
70
+ // Detect circular dependencies before building schemas
71
+ this.circularSchemas = this.detectCircularDependencies(schemasMap);
72
+ const sortedSchemaNames = this.topologicalSort(schemasMap);
67
73
  return sortedSchemaNames.reduce((schemaRegistered, name) => {
68
74
  const schema = openapi.components?.schemas?.[name];
69
75
  if (!schema)
70
76
  return schemaRegistered;
77
+ // Set context for current schema being built
78
+ this.currentSchemaName = name;
79
+ const schemaExpression = this.buildSchema(schema);
80
+ // Clear context
81
+ this.currentSchemaName = null;
71
82
  const variableStatement = ts.factory.createVariableStatement([ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], ts.factory.createVariableDeclarationList([
72
- ts.factory.createVariableDeclaration(ts.factory.createIdentifier(this.typeBuilder.sanitizeIdentifier(name)), undefined, undefined, this.buildSchema(schema)),
83
+ ts.factory.createVariableDeclaration(ts.factory.createIdentifier(this.typeBuilder.sanitizeIdentifier(name)), undefined, undefined, schemaExpression),
73
84
  ], ts.NodeFlags.Const));
74
85
  return {
75
86
  ...schemaRegistered,
@@ -1223,7 +1234,114 @@ export class TypeScriptCodeGeneratorService {
1223
1234
  buildFromReference(reference) {
1224
1235
  const { $ref = '' } = Reference.parse(reference);
1225
1236
  const refName = $ref.split('/').pop() ?? 'never';
1226
- return ts.factory.createIdentifier(this.typeBuilder.sanitizeIdentifier(refName));
1237
+ const sanitizedRefName = this.typeBuilder.sanitizeIdentifier(refName);
1238
+ // Check if this reference creates a circular dependency
1239
+ if (this.isCircularReference(refName)) {
1240
+ // Generate: z.lazy(() => RefSchema)
1241
+ return ts.factory.createCallExpression(ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier('z'), ts.factory.createIdentifier('lazy')), undefined, [
1242
+ ts.factory.createArrowFunction(undefined, undefined, [], undefined, ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), ts.factory.createIdentifier(sanitizedRefName)),
1243
+ ]);
1244
+ }
1245
+ return ts.factory.createIdentifier(sanitizedRefName);
1246
+ }
1247
+ /**
1248
+ * Determines if a reference creates a circular dependency that needs z.lazy().
1249
+ * A reference is circular if:
1250
+ * 1. It's a direct self-reference (schema references itself)
1251
+ * 2. It's part of a circular dependency chain (A -> B -> A)
1252
+ */
1253
+ isCircularReference(refName) {
1254
+ // Case 1: Direct self-reference
1255
+ if (refName === this.currentSchemaName) {
1256
+ return true;
1257
+ }
1258
+ // Case 2: Reference to a schema that's part of a circular dependency chain
1259
+ // and we're currently building a schema that's also in that chain
1260
+ if (this.circularSchemas.has(refName) &&
1261
+ this.currentSchemaName !== null &&
1262
+ this.circularSchemas.has(this.currentSchemaName)) {
1263
+ return true;
1264
+ }
1265
+ return false;
1266
+ }
1267
+ /**
1268
+ * Detects schemas that are part of circular dependency chains.
1269
+ * Uses a modified Tarjan's algorithm to find strongly connected components (SCCs).
1270
+ * Schemas in SCCs with more than one node, or self-referencing schemas, are circular.
1271
+ */
1272
+ detectCircularDependencies(schemas) {
1273
+ const circularSchemas = new Set();
1274
+ // Build dependency graph
1275
+ const graph = new Map();
1276
+ for (const [name, schema] of Object.entries(schemas)) {
1277
+ const dependencies = jp
1278
+ .query(schema, '$..["$ref"]')
1279
+ .filter((ref) => ref.startsWith('#/components/schemas/'))
1280
+ .map((ref) => ref.replace('#/components/schemas/', ''))
1281
+ .filter((dep) => dep in schemas);
1282
+ graph.set(name, dependencies);
1283
+ }
1284
+ // Tarjan's algorithm for finding SCCs
1285
+ let index = 0;
1286
+ const indices = new Map();
1287
+ const lowlinks = new Map();
1288
+ const onStack = new Set();
1289
+ const stack = [];
1290
+ const strongConnect = (node) => {
1291
+ indices.set(node, index);
1292
+ lowlinks.set(node, index);
1293
+ index++;
1294
+ stack.push(node);
1295
+ onStack.add(node);
1296
+ const neighbors = graph.get(node) ?? [];
1297
+ for (const neighbor of neighbors) {
1298
+ if (!indices.has(neighbor)) {
1299
+ strongConnect(neighbor);
1300
+ const currentLowlink = lowlinks.get(node) ?? 0;
1301
+ const neighborLowlink = lowlinks.get(neighbor) ?? 0;
1302
+ lowlinks.set(node, Math.min(currentLowlink, neighborLowlink));
1303
+ }
1304
+ else if (onStack.has(neighbor)) {
1305
+ const currentLowlink = lowlinks.get(node) ?? 0;
1306
+ const neighborIndex = indices.get(neighbor) ?? 0;
1307
+ lowlinks.set(node, Math.min(currentLowlink, neighborIndex));
1308
+ }
1309
+ }
1310
+ // If node is a root of an SCC
1311
+ if (lowlinks.get(node) === indices.get(node)) {
1312
+ const scc = [];
1313
+ let w;
1314
+ do {
1315
+ w = stack.pop();
1316
+ if (w !== undefined) {
1317
+ onStack.delete(w);
1318
+ scc.push(w);
1319
+ }
1320
+ } while (w !== undefined && w !== node);
1321
+ // An SCC is circular if it has more than one node
1322
+ // or if it has one node that references itself
1323
+ if (scc.length > 1) {
1324
+ for (const schemaName of scc) {
1325
+ circularSchemas.add(schemaName);
1326
+ }
1327
+ }
1328
+ else if (scc.length === 1) {
1329
+ const schemaName = scc[0];
1330
+ if (schemaName !== undefined) {
1331
+ const deps = graph.get(schemaName) ?? [];
1332
+ if (deps.includes(schemaName)) {
1333
+ circularSchemas.add(schemaName);
1334
+ }
1335
+ }
1336
+ }
1337
+ }
1338
+ };
1339
+ for (const node of graph.keys()) {
1340
+ if (!indices.has(node)) {
1341
+ strongConnect(node);
1342
+ }
1343
+ }
1344
+ return circularSchemas;
1227
1345
  }
1228
1346
  topologicalSort(schemas) {
1229
1347
  const visited = new Set();
package/package.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "typescript": "^5.9.3",
18
18
  "url-pattern": "^1.0.3",
19
19
  "yargs": "^18.0.0",
20
- "zod": "^4.1.13"
20
+ "zod": "^4.2.1"
21
21
  },
22
22
  "description": "A powerful TypeScript code generator that creates Zod schemas and type-safe clients from OpenAPI specifications",
23
23
  "keywords": [
@@ -34,18 +34,18 @@
34
34
  "devDependencies": {
35
35
  "@commitlint/cli": "^20.1.0",
36
36
  "@commitlint/config-conventional": "^20.0.0",
37
- "@eslint/js": "^9.39.1",
37
+ "@eslint/js": "^9.39.2",
38
38
  "@semantic-release/changelog": "^6.0.3",
39
39
  "@semantic-release/git": "^10.0.1",
40
40
  "@types/debug": "^4.1.12",
41
41
  "@types/jest": "^30.0.0",
42
42
  "@types/js-yaml": "^4.0.9",
43
43
  "@types/jsonpath": "^0.2.4",
44
- "@types/node": "^24.10.1",
44
+ "@types/node": "^25.0.2",
45
45
  "@types/yargs": "^17.0.35",
46
46
  "@vitest/coverage-v8": "^4.0.14",
47
47
  "cross-env": "^10.1.0",
48
- "eslint": "^9.39.1",
48
+ "eslint": "^9.39.2",
49
49
  "eslint-config-prettier": "^10.1.8",
50
50
  "husky": "^9.1.7",
51
51
  "lint-staged": "^16.2.7",
@@ -110,5 +110,5 @@
110
110
  "release": "semantic-release",
111
111
  "release:dry": "semantic-release --dry-run"
112
112
  },
113
- "version": "1.4.1"
113
+ "version": "1.5.0"
114
114
  }
@@ -21,6 +21,10 @@ export class TypeScriptCodeGeneratorService implements CodeGenerator, SchemaBuil
21
21
  private readonly namingConvention: NamingConvention | undefined;
22
22
  private readonly operationNameTransformer: OperationNameTransformer | undefined;
23
23
 
24
+ // Track circular dependencies for z.lazy() wrapping
25
+ private circularSchemas = new Set<string>();
26
+ private currentSchemaName: string | null = null;
27
+
24
28
  constructor(options: GeneratorOptions = {}) {
25
29
  this.namingConvention = options.namingConvention;
26
30
  this.operationNameTransformer = options.operationNameTransformer;
@@ -85,12 +89,25 @@ export class TypeScriptCodeGeneratorService implements CodeGenerator, SchemaBuil
85
89
 
86
90
  private buildSchemas(openapi: OpenApiSpecType): Record<string, ts.VariableStatement> {
87
91
  const schemasEntries = Object.entries(openapi.components?.schemas ?? {});
88
- const sortedSchemaNames = this.topologicalSort(Object.fromEntries(schemasEntries));
92
+ const schemasMap = Object.fromEntries(schemasEntries);
93
+
94
+ // Detect circular dependencies before building schemas
95
+ this.circularSchemas = this.detectCircularDependencies(schemasMap);
96
+
97
+ const sortedSchemaNames = this.topologicalSort(schemasMap);
89
98
 
90
99
  return sortedSchemaNames.reduce<Record<string, ts.VariableStatement>>((schemaRegistered, name) => {
91
100
  const schema = openapi.components?.schemas?.[name];
92
101
  if (!schema) return schemaRegistered;
93
102
 
103
+ // Set context for current schema being built
104
+ this.currentSchemaName = name;
105
+
106
+ const schemaExpression = this.buildSchema(schema);
107
+
108
+ // Clear context
109
+ this.currentSchemaName = null;
110
+
94
111
  const variableStatement = ts.factory.createVariableStatement(
95
112
  [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
96
113
  ts.factory.createVariableDeclarationList(
@@ -99,7 +116,7 @@ export class TypeScriptCodeGeneratorService implements CodeGenerator, SchemaBuil
99
116
  ts.factory.createIdentifier(this.typeBuilder.sanitizeIdentifier(name)),
100
117
  undefined,
101
118
  undefined,
102
- this.buildSchema(schema),
119
+ schemaExpression,
103
120
  ),
104
121
  ],
105
122
  ts.NodeFlags.Const,
@@ -2688,10 +2705,145 @@ export class TypeScriptCodeGeneratorService implements CodeGenerator, SchemaBuil
2688
2705
  return false;
2689
2706
  }
2690
2707
 
2691
- private buildFromReference(reference: ReferenceType): ts.Identifier {
2708
+ private buildFromReference(reference: ReferenceType): ts.CallExpression | ts.Identifier {
2692
2709
  const {$ref = ''} = Reference.parse(reference);
2693
2710
  const refName = $ref.split('/').pop() ?? 'never';
2694
- return ts.factory.createIdentifier(this.typeBuilder.sanitizeIdentifier(refName));
2711
+ const sanitizedRefName = this.typeBuilder.sanitizeIdentifier(refName);
2712
+
2713
+ // Check if this reference creates a circular dependency
2714
+ if (this.isCircularReference(refName)) {
2715
+ // Generate: z.lazy(() => RefSchema)
2716
+ return ts.factory.createCallExpression(
2717
+ ts.factory.createPropertyAccessExpression(
2718
+ ts.factory.createIdentifier('z'),
2719
+ ts.factory.createIdentifier('lazy'),
2720
+ ),
2721
+ undefined,
2722
+ [
2723
+ ts.factory.createArrowFunction(
2724
+ undefined,
2725
+ undefined,
2726
+ [],
2727
+ undefined,
2728
+ ts.factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken),
2729
+ ts.factory.createIdentifier(sanitizedRefName),
2730
+ ),
2731
+ ],
2732
+ );
2733
+ }
2734
+
2735
+ return ts.factory.createIdentifier(sanitizedRefName);
2736
+ }
2737
+
2738
+ /**
2739
+ * Determines if a reference creates a circular dependency that needs z.lazy().
2740
+ * A reference is circular if:
2741
+ * 1. It's a direct self-reference (schema references itself)
2742
+ * 2. It's part of a circular dependency chain (A -> B -> A)
2743
+ */
2744
+ private isCircularReference(refName: string): boolean {
2745
+ // Case 1: Direct self-reference
2746
+ if (refName === this.currentSchemaName) {
2747
+ return true;
2748
+ }
2749
+
2750
+ // Case 2: Reference to a schema that's part of a circular dependency chain
2751
+ // and we're currently building a schema that's also in that chain
2752
+ if (
2753
+ this.circularSchemas.has(refName) &&
2754
+ this.currentSchemaName !== null &&
2755
+ this.circularSchemas.has(this.currentSchemaName)
2756
+ ) {
2757
+ return true;
2758
+ }
2759
+
2760
+ return false;
2761
+ }
2762
+
2763
+ /**
2764
+ * Detects schemas that are part of circular dependency chains.
2765
+ * Uses a modified Tarjan's algorithm to find strongly connected components (SCCs).
2766
+ * Schemas in SCCs with more than one node, or self-referencing schemas, are circular.
2767
+ */
2768
+ private detectCircularDependencies(schemas: Record<string, unknown>): Set<string> {
2769
+ const circularSchemas = new Set<string>();
2770
+
2771
+ // Build dependency graph
2772
+ const graph = new Map<string, string[]>();
2773
+ for (const [name, schema] of Object.entries(schemas)) {
2774
+ const dependencies = jp
2775
+ .query(schema, '$..["$ref"]')
2776
+ .filter((ref: string) => ref.startsWith('#/components/schemas/'))
2777
+ .map((ref: string) => ref.replace('#/components/schemas/', ''))
2778
+ .filter((dep: string) => dep in schemas);
2779
+ graph.set(name, dependencies);
2780
+ }
2781
+
2782
+ // Tarjan's algorithm for finding SCCs
2783
+ let index = 0;
2784
+ const indices = new Map<string, number>();
2785
+ const lowlinks = new Map<string, number>();
2786
+ const onStack = new Set<string>();
2787
+ const stack: string[] = [];
2788
+
2789
+ const strongConnect = (node: string): void => {
2790
+ indices.set(node, index);
2791
+ lowlinks.set(node, index);
2792
+ index++;
2793
+ stack.push(node);
2794
+ onStack.add(node);
2795
+
2796
+ const neighbors = graph.get(node) ?? [];
2797
+ for (const neighbor of neighbors) {
2798
+ if (!indices.has(neighbor)) {
2799
+ strongConnect(neighbor);
2800
+ const currentLowlink = lowlinks.get(node) ?? 0;
2801
+ const neighborLowlink = lowlinks.get(neighbor) ?? 0;
2802
+ lowlinks.set(node, Math.min(currentLowlink, neighborLowlink));
2803
+ } else if (onStack.has(neighbor)) {
2804
+ const currentLowlink = lowlinks.get(node) ?? 0;
2805
+ const neighborIndex = indices.get(neighbor) ?? 0;
2806
+ lowlinks.set(node, Math.min(currentLowlink, neighborIndex));
2807
+ }
2808
+ }
2809
+
2810
+ // If node is a root of an SCC
2811
+ if (lowlinks.get(node) === indices.get(node)) {
2812
+ const scc: string[] = [];
2813
+ let w: string | undefined;
2814
+ do {
2815
+ w = stack.pop();
2816
+ if (w !== undefined) {
2817
+ onStack.delete(w);
2818
+ scc.push(w);
2819
+ }
2820
+ } while (w !== undefined && w !== node);
2821
+
2822
+ // An SCC is circular if it has more than one node
2823
+ // or if it has one node that references itself
2824
+ if (scc.length > 1) {
2825
+ for (const schemaName of scc) {
2826
+ circularSchemas.add(schemaName);
2827
+ }
2828
+ } else if (scc.length === 1) {
2829
+ const schemaName = scc[0];
2830
+ if (schemaName !== undefined) {
2831
+ const deps = graph.get(schemaName) ?? [];
2832
+ if (deps.includes(schemaName)) {
2833
+ circularSchemas.add(schemaName);
2834
+ }
2835
+ }
2836
+ }
2837
+ }
2838
+ };
2839
+
2840
+ for (const node of graph.keys()) {
2841
+ if (!indices.has(node)) {
2842
+ strongConnect(node);
2843
+ }
2844
+ }
2845
+
2846
+ return circularSchemas;
2695
2847
  }
2696
2848
 
2697
2849
  private topologicalSort(schemas: Record<string, unknown>): string[] {
@@ -506,4 +506,200 @@ describe('TypeScriptCodeGeneratorService', () => {
506
506
  expect(result).toBeDefined();
507
507
  });
508
508
  });
509
+
510
+ describe('circular dependencies', () => {
511
+ it('should use z.lazy() for direct self-referencing schemas', () => {
512
+ const spec: OpenApiSpecType = {
513
+ openapi: '3.0.0',
514
+ info: {
515
+ title: 'Test API',
516
+ version: '1.0.0',
517
+ },
518
+ paths: {},
519
+ components: {
520
+ schemas: {
521
+ TreeNode: {
522
+ type: 'object',
523
+ properties: {
524
+ value: {type: 'string'},
525
+ children: {
526
+ type: 'array',
527
+ items: {$ref: '#/components/schemas/TreeNode'},
528
+ },
529
+ },
530
+ required: ['value'],
531
+ },
532
+ },
533
+ },
534
+ };
535
+
536
+ const code = generator.generate(spec);
537
+ expect(code).toContain('z.lazy(() => TreeNode)');
538
+ });
539
+
540
+ it('should use z.lazy() for indirect circular dependencies (A -> B -> A)', () => {
541
+ const spec: OpenApiSpecType = {
542
+ openapi: '3.0.0',
543
+ info: {
544
+ title: 'Test API',
545
+ version: '1.0.0',
546
+ },
547
+ paths: {},
548
+ components: {
549
+ schemas: {
550
+ Person: {
551
+ type: 'object',
552
+ properties: {
553
+ name: {type: 'string'},
554
+ bestFriend: {$ref: '#/components/schemas/Friend'},
555
+ },
556
+ required: ['name'],
557
+ },
558
+ Friend: {
559
+ type: 'object',
560
+ properties: {
561
+ nickname: {type: 'string'},
562
+ person: {$ref: '#/components/schemas/Person'},
563
+ },
564
+ required: ['nickname'],
565
+ },
566
+ },
567
+ },
568
+ };
569
+
570
+ const code = generator.generate(spec);
571
+ // Both references should use z.lazy()
572
+ expect(code).toContain('z.lazy(() => Friend)');
573
+ expect(code).toContain('z.lazy(() => Person)');
574
+ });
575
+
576
+ it('should not use z.lazy() for non-circular references', () => {
577
+ const spec: OpenApiSpecType = {
578
+ openapi: '3.0.0',
579
+ info: {
580
+ title: 'Test API',
581
+ version: '1.0.0',
582
+ },
583
+ paths: {},
584
+ components: {
585
+ schemas: {
586
+ User: {
587
+ type: 'object',
588
+ properties: {
589
+ id: {type: 'integer'},
590
+ profile: {$ref: '#/components/schemas/Profile'},
591
+ },
592
+ required: ['id'],
593
+ },
594
+ Profile: {
595
+ type: 'object',
596
+ properties: {
597
+ name: {type: 'string'},
598
+ },
599
+ required: ['name'],
600
+ },
601
+ },
602
+ },
603
+ };
604
+
605
+ const code = generator.generate(spec);
606
+ // Profile should be referenced directly, not with z.lazy()
607
+ expect(code).not.toContain('z.lazy(() => Profile)');
608
+ expect(code).toContain('profile: Profile.optional()');
609
+ });
610
+
611
+ it('should handle complex circular chains (A -> B -> C -> A)', () => {
612
+ const spec: OpenApiSpecType = {
613
+ openapi: '3.0.0',
614
+ info: {
615
+ title: 'Test API',
616
+ version: '1.0.0',
617
+ },
618
+ paths: {},
619
+ components: {
620
+ schemas: {
621
+ Alpha: {
622
+ type: 'object',
623
+ properties: {
624
+ beta: {$ref: '#/components/schemas/Beta'},
625
+ },
626
+ },
627
+ Beta: {
628
+ type: 'object',
629
+ properties: {
630
+ gamma: {$ref: '#/components/schemas/Gamma'},
631
+ },
632
+ },
633
+ Gamma: {
634
+ type: 'object',
635
+ properties: {
636
+ alpha: {$ref: '#/components/schemas/Alpha'},
637
+ },
638
+ },
639
+ },
640
+ },
641
+ };
642
+
643
+ const code = generator.generate(spec);
644
+ // All references in the cycle should use z.lazy()
645
+ expect(code).toContain('z.lazy(() => Beta)');
646
+ expect(code).toContain('z.lazy(() => Gamma)');
647
+ expect(code).toContain('z.lazy(() => Alpha)');
648
+ });
649
+
650
+ it('should handle self-referencing schemas in arrays', () => {
651
+ const spec: OpenApiSpecType = {
652
+ openapi: '3.0.0',
653
+ info: {
654
+ title: 'Test API',
655
+ version: '1.0.0',
656
+ },
657
+ paths: {},
658
+ components: {
659
+ schemas: {
660
+ Category: {
661
+ type: 'object',
662
+ properties: {
663
+ name: {type: 'string'},
664
+ subcategories: {
665
+ type: 'array',
666
+ items: {$ref: '#/components/schemas/Category'},
667
+ },
668
+ },
669
+ required: ['name'],
670
+ },
671
+ },
672
+ },
673
+ };
674
+
675
+ const code = generator.generate(spec);
676
+ expect(code).toContain('z.array(z.lazy(() => Category))');
677
+ });
678
+
679
+ it('should handle self-referencing schemas in anyOf', () => {
680
+ const spec: OpenApiSpecType = {
681
+ openapi: '3.0.0',
682
+ info: {
683
+ title: 'Test API',
684
+ version: '1.0.0',
685
+ },
686
+ paths: {},
687
+ components: {
688
+ schemas: {
689
+ Expression: {
690
+ type: 'object',
691
+ properties: {
692
+ value: {
693
+ anyOf: [{type: 'string'}, {$ref: '#/components/schemas/Expression'}],
694
+ },
695
+ },
696
+ },
697
+ },
698
+ },
699
+ };
700
+
701
+ const code = generator.generate(spec);
702
+ expect(code).toContain('z.lazy(() => Expression)');
703
+ });
704
+ });
509
705
  });