Skip to content

Commit 5fd3022

Browse files
Copilotardatan
andauthored
Fix TS2615 circular reference error with mutually-recursive OpenAPI schemas (e.g. Argo Workflows) (#3665)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ardatan <20847995+ardatan@users.noreply.github.com>
1 parent f7b2a42 commit 5fd3022

File tree

4 files changed

+169
-5
lines changed

4 files changed

+169
-5
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"fets": patch
3+
---
4+
5+
Fix "Type of property circularly references itself" error for mutually-recursive schemas (e.g. Argo Workflows OpenAPI spec)
6+
7+
Large OpenAPI specs such as Argo Workflows contain **mutually-recursive** schema references (e.g. `Template → DAGTemplate → DAGTask → Template`). These schemas caused TypeScript error TS2615 when used with `OASModel`, `OASOutput`, `OASJSONResponseSchema`, or `createClient`.
8+
9+
The root cause was in the `Circular<T>` type helper, which detected only:
10+
1. Direct self-references (`child: Node` where `Node.child` resolves back to the same `Node` type)
11+
2. Array self-references added in a previous fix (`children: Node[]`)
12+
13+
It did **not** detect indirect mutual recursion (Schema A → Schema B → Schema A), because the recursive type check `Circular<PropertyValue<A>>` eventually calls `Circular<A>` again, causing TypeScript to hit its recursion limit and silently fail, leaving the deserializer enabled and triggering TS2615 inside `json-schema-to-ts`.
14+
15+
The fix extends `Circular<T>` to also return `true` when any property of the schema is a **resolved `$ref`** — identified by the presence of a `$id` field that `NormalizeOAS` injects. Such schemas were resolved from `$ref` entries in the original OpenAPI document and may participate in complex circular reference chains. Disabling the deserializer expansion for these schemas avoids the TS2615 error.
16+
17+
**Before (broken):**
18+
```typescript
19+
// Template → DAGTemplate → DAGTask → Template caused TS2615
20+
type NormalizedArgo = NormalizeOAS<typeof argoWorkflowsOAS>;
21+
type TemplateType = OASModel<NormalizedArgo, 'Template'>; // Error: TS2615
22+
const client = createClient<NormalizedArgo>({});
23+
await client['/workflow'].get(); // Error: TS2615
24+
```
25+
26+
**After (fixed):**
27+
```typescript
28+
type NormalizedArgo = NormalizeOAS<typeof argoWorkflowsOAS>;
29+
type TemplateType = OASModel<NormalizedArgo, 'Template'>; // Works!
30+
const client = createClient<NormalizedArgo>({});
31+
const res = await client['/workflow'].get(); // Works!
32+
if (res.ok) {
33+
const body = await res.json();
34+
body.dag?.tasks?.[0]?.name; // correctly typed as string | undefined
35+
}
36+
```

packages/fets/src/types.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,13 @@ export type Circular<TJSONSchema extends JSONSchema> = TJSONSchema extends {
167167
}
168168
? TJSONSchema extends PropertyValue<TJSONSchema, Property<TJSONSchema>>
169169
? true
170-
: [ArrayItemValue<PropertyValue<TJSONSchema, Property<TJSONSchema>>>] extends [never]
171-
? Circular<PropertyValue<TJSONSchema, Property<TJSONSchema>>>
172-
: ArrayItemValue<PropertyValue<TJSONSchema, Property<TJSONSchema>>> extends TJSONSchema
173-
? true
174-
: Circular<PropertyValue<TJSONSchema, Property<TJSONSchema>>>
170+
: [Extract<PropertyValue<TJSONSchema, Property<TJSONSchema>>, { $id: string }>] extends [never]
171+
? [ArrayItemValue<PropertyValue<TJSONSchema, Property<TJSONSchema>>>] extends [never]
172+
? Circular<PropertyValue<TJSONSchema, Property<TJSONSchema>>>
173+
: ArrayItemValue<PropertyValue<TJSONSchema, Property<TJSONSchema>>> extends TJSONSchema
174+
? true
175+
: Circular<PropertyValue<TJSONSchema, Property<TJSONSchema>>>
176+
: true
175177
: false;
176178

177179
export type Property<TJSONSchema extends JSONSchema> = keyof TJSONSchema['properties'];
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Simplified schema that reproduces the mutual recursion pattern found in large OpenAPI specs
3+
* such as Argo Workflows: Template → DAGTemplate → DAGTask → Template (circular).
4+
*
5+
* Before the fix, using OASModel with such schemas caused TypeScript error TS2615:
6+
* "Type of property 'X' circularly references itself in mapped type 'Y'"
7+
*/
8+
export default {
9+
openapi: '3.0.3',
10+
info: {
11+
version: '1',
12+
title: 'Mutual Circular Ref - OpenAPI 3.0',
13+
description: 'This is a sample of mutually recursive schemas (Argo-like pattern)',
14+
termsOfService: 'http://swagger.io/terms/',
15+
},
16+
paths: {
17+
'/workflow': {
18+
get: {
19+
tags: ['workflow'],
20+
summary: 'Get workflow',
21+
description: '',
22+
operationId: 'getWorkflow',
23+
responses: {
24+
'200': {
25+
description: 'successful operation',
26+
content: {
27+
'application/json': {
28+
schema: {
29+
$ref: '#/components/schemas/Template',
30+
},
31+
},
32+
},
33+
},
34+
'404': {
35+
description: 'Workflow not found',
36+
},
37+
},
38+
},
39+
},
40+
},
41+
components: {
42+
schemas: {
43+
Template: {
44+
type: 'object',
45+
properties: {
46+
name: {
47+
type: 'string',
48+
},
49+
dag: {
50+
$ref: '#/components/schemas/DAGTemplate',
51+
},
52+
},
53+
},
54+
DAGTemplate: {
55+
type: 'object',
56+
properties: {
57+
tasks: {
58+
type: 'array',
59+
items: {
60+
$ref: '#/components/schemas/DAGTask',
61+
},
62+
},
63+
},
64+
},
65+
DAGTask: {
66+
type: 'object',
67+
properties: {
68+
name: {
69+
type: 'string',
70+
},
71+
template: {
72+
$ref: '#/components/schemas/Template',
73+
},
74+
},
75+
},
76+
},
77+
},
78+
} as const;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createClient, OASModel, OASOutput, type NormalizeOAS } from 'fets';
2+
import type workflowOAS from './fixtures/example-mutual-circular-ref-oas';
3+
4+
// This resolves mutual circular reference (Template → DAGTemplate → DAGTask → Template)
5+
type NormalizedOAS = NormalizeOAS<typeof workflowOAS>;
6+
7+
// OASModel should work without TS2615 for mutually-recursive schemas
8+
type TemplateModel = OASModel<NormalizedOAS, 'Template'>;
9+
const template = {} as TemplateModel;
10+
11+
// Should be able to navigate the mutually-recursive structure
12+
const taskName = template.dag?.tasks?.[0]?.name;
13+
type TaskNameType = typeof taskName;
14+
let taskNameVar: TaskNameType;
15+
taskNameVar = 'my-task';
16+
// @ts-expect-error - taskNameVar is a string
17+
taskNameVar = 42;
18+
19+
console.log(taskNameVar);
20+
21+
// OASOutput should also work
22+
type TemplateOutput = OASOutput<NormalizedOAS, '/workflow', 'get', '200'>;
23+
const templateOutput = {} as TemplateOutput;
24+
const outputTaskName = templateOutput.dag?.tasks?.[0]?.name;
25+
type OutputTaskNameType = typeof outputTaskName;
26+
let outputTaskNameVar: OutputTaskNameType;
27+
outputTaskNameVar = 'another-task';
28+
// @ts-expect-error - outputTaskNameVar is a string
29+
outputTaskNameVar = 42;
30+
31+
console.log(outputTaskNameVar);
32+
33+
// createClient should also work without TS2615
34+
const client = createClient<NormalizedOAS>({});
35+
const response = await client['/workflow'].get();
36+
37+
if (response.ok) {
38+
const body = await response.json();
39+
const nestedTaskName = body.dag?.tasks?.[0]?.name;
40+
type NestedTaskNameType = typeof nestedTaskName;
41+
let nestedTaskNameVar: NestedTaskNameType;
42+
nestedTaskNameVar = 'nested-task';
43+
// @ts-expect-error - nestedTaskNameVar is a string
44+
nestedTaskNameVar = 42;
45+
console.log(nestedTaskNameVar);
46+
} else {
47+
console.log(response.status);
48+
}

0 commit comments

Comments
 (0)