Fixing OpenAPI $ref Circular Reference Errors
A single self-referencing schema — a Category whose children are also Category objects — is enough to crash bundlers, halt codegen pipelines, and produce Converting circular structure to JSON or Maximum call stack size exceeded errors across the toolchain. This guide is part of the Compile-Time Type Generation from OpenAPI workflow and shows exactly why cycles break tools, how to restructure the spec to fix them, and which tools handle circularity gracefully so generation keeps working.
Symptom
The failure appears in one of three forms depending on which tool hits the cycle first.
During bundling or dereferencing:
$ npx @redocly/cli bundle --dereferenced openapi.yaml -o bundle.json
Error: Converting circular structure to JSON
at JSON.stringify (<anonymous>)
at dereference (node_modules/@redocly/openapi-core/src/bundle.ts:214)
During codegen with an inlining pre-processor:
$ npx swagger-codegen generate -i openapi.yaml -l typescript-fetch -o ./client
[ERROR] java.lang.StackOverflowError
at io.swagger.parser.util.RefUtils.isAnExternalRefFormat
During openapi-typescript with a fully-dereferenced input:
$ npx openapi-typescript bundle.json -o api-types.ts
RangeError: Maximum call stack size exceeded
at Object.<anonymous> (node_modules/openapi-typescript/dist/index.cjs:1:12345)
All three share the same root cause: a tool attempted to inline or serialize a schema that references itself, and the traversal has no base case.
Root Cause
Inline schemas vs. named components
OpenAPI allows schemas to appear inline — nested directly inside a properties object, a requestBody, or an array items field. Inline schemas have no name and no stable JSON Pointer address. When a bundler encounters a $ref that points back to a schema that is currently being inlined, it has no address to leave as a pointer — so it tries to copy the schema in place. That triggers another traversal of the same schema, which triggers another copy, recursing until the call stack overflows.
Named schemas in components/schemas have a stable JSON Pointer (#/components/schemas/Category). A bundler that encounters a $ref back to a named component can leave the reference as a pointer — it does not need to inline it — because the target has a well-defined location in the output document.
The rule: any schema that participates in a cycle must live in components/schemas, never inline.
JSON Pointer and the cycle
A circular $ref is a JSON Pointer that, when followed, eventually leads back to a node already on the traversal stack. The two patterns that cause cycles:
Self-referential (direct): Category has a children property whose items are Category.
# JSON Pointer to the cycle: #/components/schemas/Category
# The $ref at children.items points back to: #/components/schemas/Category
Mutual recursion (indirect): TreeNode references BinaryTree; BinaryTree references TreeNode.
# Path of the cycle:
# #/components/schemas/TreeNode -> properties.subtree.$ref
# -> #/components/schemas/BinaryTree -> properties.root.$ref
# -> #/components/schemas/TreeNode (cycle)
Both patterns are structurally valid JSON Schema and valid OpenAPI 3.x. The problem is not in the spec — it is in tools that attempt full dereferencing instead of tracking visited refs.
Step-by-Step Fix
Step 1: Move all cycle participants to named components
Every schema involved in a cycle — including schemas that only transitively touch the cycle — must be a named entry in components/schemas with its own $ref. Remove all inline definitions for those schemas.
Before (broken — inline schema, triggers stack overflow on bundling):
# openapi.yaml — DO NOT copy this pattern
paths:
/categories:
get:
responses:
'200':
description: Category tree
content:
application/json:
schema:
type: object
properties:
id:
type: integer
name:
type: string
children:
type: array
items:
# !! INLINE recursive reference — bundler has no pointer target
type: object
properties:
id:
type: integer
name:
type: string
children:
type: array
items: {} # silently broken; most tools give up here
After (fixed — named component, cycle expressed as a $ref pointer):
# openapi.yaml — correct pattern
paths:
/categories:
get:
responses:
'200':
description: Category tree
content:
application/json:
schema:
$ref: '#/components/schemas/Category' # ← pointer, not copy
components:
schemas:
Category:
type: object
required:
- id
- name
properties:
id:
type: integer
example: 42
name:
type: string
example: "Electronics"
children:
type: array
items:
$ref: '#/components/schemas/Category' # ← self-referential pointer; safe
Why this works: The bundler encounters $ref: '#/components/schemas/Category' while processing Category. Because the target has a stable address and the bundler tracks visited paths, it leaves the pointer in place instead of inlining. No infinite recursion.
Step 2: Validate the fixed spec
Run the linter against the updated document before attempting any generation or bundling. Circular refs that are structurally invalid (e.g., a required property that always requires itself with no termination path) are a modelling error and should be caught here.
# Requires @redocly/cli 1.x
npx @redocly/cli lint openapi.yaml
A valid self-referential schema passes lint with no warnings. If you see Circular $ref errors from Redocly, verify that the cycle participants are all named in components/schemas and that no inline schema object tries to embed itself.
Step 3: Bundle without full dereferencing
When pre-processing is required (e.g., to merge multi-file specs into a single document for tools that cannot follow external references), use redocly bundle in its default mode — which resolves external file refs but preserves internal $ref pointers. Never pass --dereferenced when cycles are present.
# Flattens external $ref (./schemas/category.yaml) into the bundle.
# Preserves internal $ref (#/components/schemas/Category) as-is.
npx @redocly/cli bundle openapi.yaml -o openapi.bundled.yaml
# Confirm the output still contains the $ref pointer (not an inlined copy).
grep "'\$ref'" openapi.bundled.yaml
# Expected: $ref: '#/components/schemas/Category'
If the bundled output contains a $ref pointing back to #/components/schemas/Category, the cycle is correctly represented. If the field is missing or replaced by an empty {} object, a tool in the pipeline silently dropped the cycle — treat that as a bug in the pre-processing step.
Step 4: Generate TypeScript types with openapi-typescript 7
openapi-typescript 7 handles circular $ref natively. It traverses the spec as a directed graph, detects back-edges, and emits TypeScript interface declarations that reference themselves by name — exactly the pattern TypeScript’s own type system supports via recursive interfaces.
# Install once; pin the exact version so local and CI output match.
npm install --save-dev openapi-typescript@7
# Generate types from the bundled spec.
npx openapi-typescript ./openapi.bundled.yaml \
--output ./src/generated/api-types.ts \
--root-types
The relevant slice of the generated output for the Category schema:
// src/generated/api-types.ts (auto-generated — do not edit)
export interface components {
schemas: {
Category: {
id: number;
name: string;
/** Optional array of nested categories — recursive self-reference. */
children?: components["schemas"]["Category"][];
};
// ... other schemas
};
}
// With --root-types, a bare alias is also exported:
export type Category = components["schemas"]["Category"];
The recursive reference components["schemas"]["Category"][] is valid TypeScript — the compiler resolves it lazily when the type is used, not when it is declared, so there is no runtime recursion and no compile error.
Before / After Comparison
| Dimension | Before (inline cycle) | After (named component) |
|---|---|---|
| Bundler output | Stack overflow or empty {} |
Valid YAML with preserved $ref pointer |
| openapi-typescript | RangeError: Maximum call stack |
Self-referential interface Category |
| TypeScript compile | Fails — no usable type | Passes — recursive type resolves lazily |
| JSON Schema validity | Ambiguous — inline has no $ref address |
Valid — $ref points to a named location |
| Swagger UI / Redoc | Renders empty or missing children | Renders tree with one-level expansion |
Verification
Run these checks in sequence. Each should exit cleanly before you proceed to the next.
# 1. Lint the source spec — no circular-ref warnings.
npx @redocly/cli lint openapi.yaml && echo "lint: OK"
# 2. Bundle without dereferencing — internal $ref preserved.
npx @redocly/cli bundle openapi.yaml -o openapi.bundled.yaml
grep "\$ref: '#/components/schemas/Category'" openapi.bundled.yaml \
&& echo "bundle: $ref preserved"
# 3. Generate types — no RangeError, file written cleanly.
npx openapi-typescript@7 ./openapi.bundled.yaml \
--output ./src/generated/api-types.ts --root-types \
&& echo "codegen: OK"
# 4. TypeScript compiles the recursive type.
npx tsc --noEmit && echo "tsc: OK"
A clean CI log looks like four consecutive OK lines. If step 2 emits a bundled file where the self-reference is absent, the pre-processor silently broke the cycle — inspect the intermediate output and check whether an upstream tool is passing --dereference or --resolve-internal.
In a GitHub Actions pipeline, add generation between lint and compile:
# .github/workflows/openapi-type-gate.yml (relevant steps only)
- name: Lint spec
run: npx @redocly/cli lint openapi.yaml
- name: Bundle (preserve internal refs)
run: npx @redocly/cli bundle openapi.yaml -o openapi.bundled.yaml
- name: Generate types
run: npx openapi-typescript ./openapi.bundled.yaml --output ./src/generated/api-types.ts --root-types
- name: Assert no drift
run: git diff --exit-code ./src/generated/api-types.ts || (echo "::error::Generated types are stale." && exit 1)
- name: Type-check consumers
run: npx tsc --noEmit
Edge Cases
Self-referential arrays with minItems constraints. A schema like children: { type: array, items: $ref Category, minItems: 1 } is valid OpenAPI but implies that every Category must have at least one child, making it impossible to represent a leaf node. The spec is structurally valid but semantically unsatisfiable. If codegen succeeds but your data fails validation, remove minItems or add a separate Leaf schema for terminal nodes.
Mutual recursion across multiple schemas. When TreeNode references BinaryTree and BinaryTree references TreeNode, both must be named in components/schemas. The fix is identical: ensure every participant in the mutual cycle is a named component. openapi-typescript 7 handles multi-schema cycles by emitting mutually referential interfaces:
export interface components {
schemas: {
TreeNode: {
value: number;
subtree?: components["schemas"]["BinaryTree"];
};
BinaryTree: {
root?: components["schemas"]["TreeNode"];
};
};
}
TypeScript resolves both lazily; there is no stack overflow at the type-system level.
Legacy tools that always dereference. Some older generators (swagger-codegen 2.x, some openapi-generator versions before 7.x) perform full dereferencing internally and cannot be configured otherwise. For those tools, the standard workaround is to break the cycle at the schema level by introducing a maxDepth field and replacing the recursive $ref with a generic object or additionalProperties for levels beyond what the tool needs to generate. This is a compatibility shim, not a long-term solution — migrate to a generator that tracks visited nodes, such as openapi-typescript 7, when the project allows it.
$ref alongside sibling keywords in OpenAPI 3.0. In OpenAPI 3.0, a $ref sibling to other keywords (e.g., description, nullable) silently ignores the siblings — the $ref takes over completely. This is fixed in OpenAPI 3.1 where $ref can be combined with other keywords. If you are on 3.0 and need a nullable recursive ref, wrap it in oneOf:
children:
type: array
items:
oneOf:
- $ref: '#/components/schemas/Category'
- type: 'null' # OpenAPI 3.0 nullable workaround
In OpenAPI 3.1, use the cleaner form:
children:
type: array
items:
$ref: '#/components/schemas/Category'
# $ref + nullable sibling is valid in 3.1:
# type: ["object", "null"] — add alongside $ref if items can be null
Frequently Asked Questions
Why does redocly bundle crash on a circular $ref but openapi-typescript succeeds?
redocly bundle in fully-dereference mode tries to inline every $ref into a flat document, which recurses forever on a cycle. openapi-typescript reads the spec as a graph and emits self-referential TypeScript interfaces, which the compiler handles natively. Run redocly bundle in its default preserve-internal-refs mode and the crash disappears.
Does openapi-typescript 7 support circular $ref out of the box?
Yes. openapi-typescript 7 resolves recursive schemas into self-referential TypeScript interfaces without any extra flags. Category references Category; the emitted interface reads children?: components["schemas"]["Category"][], which TypeScript compiles cleanly.
What is the difference between a self-referential schema and mutual recursion in OpenAPI?
A self-referential schema has a $ref that points back to itself (Category → children: Category). Mutual recursion involves two or more schemas referencing each other in a cycle (TreeNode → left: BinaryTree, BinaryTree → root: TreeNode). Both patterns require named components and $ref rather than inline schema objects.
Can I use $recursiveRef or $dynamicRef to break cycles?
Those are JSON Schema draft 2019-09 and 2020-12 keywords, available in OpenAPI 3.1. In practice, naming schemas in components/schemas and referencing them with $ref is simpler and universally supported by tooling. Reserve $dynamicRef for extension points in composable schemas.
Will Swagger UI and Redoc render circular schemas correctly?
Yes, both detect cycles and render one level of nesting before substituting a link or ellipsis, so documentation stays readable. The rendering strategy is the renderer’s concern; your spec just needs to be structurally valid with properly named components.