Quick answer

Circular references in an OpenAPI spec, where a model points back to itself directly or through a chain, are legal and common, but they expose the weakest part of a code generator. A generator may emit a type that references itself before it is defined, stack-overflow during template expansion, or quietly replace the cycle with any, which silently removes the type safety you generated code for. The fix is not to eliminate genuine cycles but to confirm the generator models them as recursive types and to break long indirect cycles with explicit component references where the output becomes unreadable.

Where cycles come from

The simplest case is a self-referential tree, such as a folder that contains child folders.

components:
  schemas:
    Folder:
      type: object
      required: [id, name]
      properties:
        id: { type: string }
        name: { type: string }
        children:
          type: array
          items:
            $ref: '#/components/schemas/Folder'

Indirect cycles are harder to see: an Order references a Customer, the Customer references a default Invoice, and the Invoice references back to the Order. Generators handle the direct case well and the chained case inconsistently, especially across language targets.

Generator outputResultSeverity
Proper recursive typeCorrect, compiles, nests to any depthNone
Forward reference to undefined typeCompile error in strict languagesHigh
Substitution with any/objectCompiles but loses inner typingSilent, high
Template recursion errorGeneration fails entirelyHigh but visible

What a correct recursive output looks like

In a language that supports referencing a type within its own definition, the generated model should name itself rather than inline an infinite expansion.

export interface Folder {
  id: string;
  name: string;
  children?: Folder[];
}

export interface Order {
  id: string;
  customer: Customer;
}
export interface Customer {
  id: string;
  defaultInvoice?: Invoice;
}
export interface Invoice {
  id: string;
  order: Order; // indirect cycle closed by named interfaces
}

The key signal is that every reference resolves to a named, declared type. If you inspect the generated output and find any exactly where a cycle exists, that is where the generator gave up.

Making cycles generator-friendly

You generally do not need to redesign the domain, but a few spec habits dramatically improve results across targets.

  1. Prefer named component schemas over deeply nested inline objects. A generator can close a cycle through a named $ref; an inline anonymous structure gives it no anchor to recurse on.
  2. Keep self-references direct for tree structures, since direct recursion is the best-supported case.
  3. For long indirect loops, verify the full chain after generation rather than assuming the generator followed every hop.
  4. Pin the generator version. Recycle handling changes between releases, and an upgrade can silently turn a working recursive type into any.

Validate the output, not just the spec

A lint-valid OpenAPI document tells you the references resolve; it says nothing about whether the generated code models them. Add a test that reads a deeply nested payload through the generated client and confirms inner nodes keep their typed shape at runtime, not just at compile time. A structural check at several nesting depths catches a generator that inlines only the first level and falls back to untyped objects below it. Run this check for every language target you publish, because the same spec can produce correct recursion in one target and any in another.

Recursion depth and unbounded payloads

A correct recursive type still leaves a runtime concern: nothing in the contract limits how deeply a tree can nest, and a generated client will happily allocate until the process runs out of memory on a pathological payload. The type system models the shape, not the size, so defense belongs in the client and, where possible, the schema. Add a maximum depth or item count guard in the deserializer and fail with a clear error when a response exceeds it; if the API can guarantee a bound, express it with maxItems and maxProperties so the generated validator can reject bad data early. This is the same principle as pagination for large lists. A recursive model that is correct at compile time is only half the work; the runtime also needs a ceiling so a deeply nested or malicious response cannot exhaust resources merely because every node is well typed.

When to break the cycle deliberately

Some cycles are accidental rather than structural, such as two models that reference each other fully when a lightweight summary would do. Replacing one side with a reference type containing only an id and a link removes the loop and reflects what the API actually returns. Do this when the payload is genuinely shallow on one side; do not flatten a real tree like the folder hierarchy, which is genuinely recursive and should be modeled that way.

FAQ

Is a circular OpenAPI reference invalid?

No. The specification allows cycles and they are common for hierarchical data. The problem is purely in how a particular generator and language target choose to represent them, not in the document's validity.

Why does the generated code compile but lose its types?

Because the generator substituted the cycle with a generic or untyped type to avoid infinite expansion. The code still compiles and runs, but nested values are no longer checked, which is why you must inspect the output at the cycle rather than trust a successful build.

Should I avoid oneOf to keep recursion simple?

Recursive references inside a oneOf are a known pain point for some generators, so prefer a direct recursive property where the schema allows it. If polymorphism is required, test that specific combination rather than assuming it works.

How do I catch a regression after a generator upgrade?

Keep the nested-payload runtime test in CI and run it on every version bump. A diff of generated types also reveals when a named recursive interface is replaced by a generic fallback.

This is part of the The Code Generation Guide — start there for the full set of codegen failure modes worth testing.