Skip to main content

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.

Circular $ref: broken inline vs. safe named-component pattern Left side: an inline Category schema with an inline children array that references Category again, with a red recursive arrow indicating a stack overflow. Right side: Category in components/schemas pointing via $ref to itself, with a green arrow indicating safe self-reference that openapi-typescript 7 handles natively.

Inline schema — BROKEN

Category (inline) name: string children: array of → ??? RangeError: Maximum call stack size exceeded

bundler inlines forever → stack overflow

Named component — SAFE

components/schemas/Category name: string children: $ref → Category interface Category { children?: Category[] }

openapi-typescript 7 emits self-referential interface

Named components break the inlining cycle — the ref is a pointer, not a copy.

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 (Categorychildren: Category). Mutual recursion involves two or more schemas referencing each other in a cycle (TreeNodeleft: BinaryTree, BinaryTreeroot: 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.