Recursive Types

Reviewed & published by Brayan K

A recursive type refers to itself within its own definition, letting you describe data of unbounded depth — JSON, trees, and linked lists — with a single type that nests as far as the data does.

Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

Think of two mirrors facing each other — each reflection contains another, smaller reflection, on and on. A recursive type is a definition that contains itself the same way: a folder holds files and other folders, which hold files and folders, without limit. The structure can go as deep as the real data does, because the type keeps referring back to itself.

1. The Canonical Example: a JSON Type

JSON is the textbook recursive type. A JSON value is a primitive, or an array of JSON values, or an object whose values are JSON — so the type name appears inside its own definition, in two places. That self-reference is exactly what allows arbitrary nesting. To process such data, you write a recursive function whose branches mirror the type's cases.

// The JSON type is the canonical recursive type — it references itself
// in TWO places (the array and the object), allowing unlimited nesting:
type Json =
  | string
  | number
  | boolean
  | null
  | Json[]                    // an array OF Json   (recursion)
  | { [key: string]: Json };  // an object of Json  (recursion)

const value: Json = {
  name: "Ada",
  scores: [95, 88],
  address: { city: "London", zip: null },
}; // ✅ every nested value is itself a valid Json
// A RECURSIVE TYPE refers to ITSELF. The classic example is JSON, which can
// nest objects and arrays to any depth. In TS:
//   type Json = string | number | boolean | null | Json[] | { [k: string]: Json };
// At runtime it's just data — we walk it recursively to process it.

const data = {
  name: "Ada",
  active: true,
  scores: [95, 88, 72],
  address: { city: "London", zip: null },
};

// A recursive walker mirrors the recursive type: handle each JSON case.
function countValues(json) {
  if (Array.isArray(json)) {
    return json.reduce((n, item) => n + countValues(item), 0);
  }
  if (json !== null && typeof json === "object") {
    return Object.values(json).reduce((n, v) => n + countValues(v), 0);
  }
  return 1; // a leaf: string | number | boolean | null
}

console.log(countValues(data));           // 7
console.log(countValues([1, [2, [3]]]));  // 3

2. Trees and Linked Lists

Two everyday structures are inherently recursive. A tree node contains an array of child nodes of the same type. A linked-list node holds a next pointer to another node of the same type, ending in null. Both definitions reference themselves, and both are traversed with recursion (for trees) or a simple loop following next (for lists).

// A tree: each node holds children that are themselves nodes.
interface TreeNode {
  value: number;
  children: TreeNode[]; // recursion: an array of the SAME type
}

// A singly linked list: each node points to the next, or null at the end.
interface ListNode {
  value: string;
  next: ListNode | null; // recursion: the next is another ListNode (or null)
}

// Both are self-referential — the type appears inside its own definition.
// Interfaces and type aliases can both be recursive this way.
// Recursive types model TREES and LINKED LISTS — structures defined in terms
// of themselves. In TS:
//   interface TreeNode { value: number; children: TreeNode[] }
//   interface ListNode { value: number; next: ListNode | null }
// At runtime they're plain nested objects we traverse recursively.

// A TREE: each node has children that are themselves nodes.
const tree = {
  value: 1,
  children: [
    { value: 2, children: [] },
    { value: 3, children: [{ value: 4, children: [] }] },
  ],
};

function sumTree(node) {
  return node.value + node.children.reduce((s, c) => s + sumTree(c), 0);
}
console.log(sumTree(tree)); // 10  (1+2+3+4)

// A LINKED LIST: each node points to the next, ending in null.
const list = { value: "a", next: { value: "b", next: { value: "c", next: null } } };

function toArray(node) {
  const out = [];
  let cur = node;
  while (cur !== null) { out.push(cur.value); cur = cur.next; }
  return out;
}
console.log(toArray(list).join(" -> ")); // a -> b -> c

3. DeepReadonly<T> & Recursion Limits

Recursive types aren't limited to data — they power recursive utility types too. DeepReadonly<T> is a mapped type that makes every nested property readonly by applying itself to each property's type. The runtime equivalent is a recursive deep-freeze. One caveat: TypeScript caps recursion depth (around 50 levels) to prevent infinite expansion, so deeply self-feeding types can hit an "excessively deep" error.

// DeepReadonly recurses into every nested object at the TYPE level:
type DeepReadonly<T> = {
  readonly [K in keyof T]: DeepReadonly<T[K]>;
};

type Config = { ui: { theme: string; layout: { columns: number } } };
type Frozen = DeepReadonly<Config>;
// Every level is readonly: Frozen["ui"]["layout"]["columns"] is readonly too.

// CAUTION: recursive types have a depth limit (about 50 levels). Very deep or
// self-feeding recursion triggers:
//   "Type instantiation is excessively deep and possibly infinite."
// Keep recursion bounded, or add a base case that stops it.
// DeepReadonly<T> is a RECURSIVE mapped type that makes every nested property
// readonly, not just the top level. In TS (sketch):
//   type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> };
// At runtime the equivalent is a recursive deep-freeze.
// NOTE: TS limits recursion DEPTH (~50 by default) to avoid infinite types.

function deepFreeze(obj) {
  for (const key of Object.keys(obj)) {
    const v = obj[key];
    if (v && typeof v === "object") deepFreeze(v);
  }
  return Object.freeze(obj);
}

const config = deepFreeze({
  ui: { theme: "dark", layout: { columns: 3 } },
});

// Every level is now locked at runtime:
config.ui.theme = "light";         // ignored
config.ui.layout.columns = 99;     // ignored
console.log(config.ui.theme);          // dark
console.log(config.ui.layout.columns); // 3
console.log(Object.isFrozen(config.ui.layout)); // true

🎯 Your Turn

Compute a tree's depth with a function that calls itself on each child. Fill in the blank marked ___ with the recursive call, then run it.

// 🎯 YOUR TURN — replace the ___ then press "Try it Yourself".
// Compute a tree's DEPTH. A leaf (no children) has depth 1; otherwise it's
// 1 plus the deepest child. The function must call ITSELF on each child.

function depth(node) {
  if (node.children.length === 0) return 1;
  return 1 + Math.max(...node.children.map(___)); // 👉 the recursive call: depth
}

const tree = {
  value: 1,
  children: [
    { value: 2, children: [] },
    { value: 3, children: [{ value: 4, children: [] }] },
  ],
};

console.log(depth(tree)); // 3

// ✅ Expected output:
//    3

Common Errors (and the fix)

Pro Tips

📚 Keep learning

Mini-Challenge: Flatten a Tree

No blanks this time — just a brief and a starting outline. Build the recursive flatten yourself, run it, and check your output against the example in the comments.

// 🎯 MINI-CHALLENGE: flatten a tree into a list of values (pre-order).
// In TS the node type is the recursive  interface TreeNode { value: string; children: TreeNode[] }.
//
// Write flatten(node) that returns an ARRAY of every value in the tree,
// visiting the node BEFORE its children:
//   1. Start with [node.value].
//   2. Recurse into each child and concatenate the results.
//
// Use the 'tree' below.
//
// ✅ Expected output:
//    root, a, b, c
//    4

const tree = {
  value: "root",
  children: [
    { value: "a", children: [{ value: "b", children: [] }] },
    { value: "c", children: [] },
  ],
};

// your code here

🎉 Lesson Complete

Practice quiz

What is a recursive type?

  • A type that runs in a loop
  • A type with many generics
  • A type whose definition refers to itself
  • A type that imports itself

Answer: A type whose definition refers to itself. A recursive type references itself, directly or indirectly, describing data of unbounded depth.

Why is the JSON type the canonical recursive example?

  • A Json value can be a primitive, an array of Json, or an object of Json
  • It is the only recursive type
  • It uses classes
  • It cannot nest

Answer: A Json value can be a primitive, an array of Json, or an object of Json. Json references itself in two places - Json[] and { [k: string]: Json } - allowing unlimited nesting.

How is a tree node modeled recursively?

  • value plus next: number
  • value only
  • children: string[]
  • value plus children: TreeNode[]

Answer: value plus children: TreeNode[]. interface TreeNode { value: number; children: TreeNode[] } - children are the same type.

How does a singly linked list node end?

  • With another node
  • With 'next: ListNode | null' where null ends the chain
  • With an empty string
  • It never ends

Answer: With 'next: ListNode | null' where null ends the chain. Each node points to the next, or null at the end of the list.

Can both interfaces and type aliases be recursive?

  • Yes, both support recursion
  • Only interfaces
  • Only type aliases
  • Neither can

Answer: Yes, both support recursion. Modern TypeScript supports recursive type aliases and recursive interfaces.

What does DeepReadonly<T> do?

  • Freezes at runtime
  • Only the top level readonly
  • Recursively makes every nested property readonly at the type level
  • Removes readonly

Answer: Recursively makes every nested property readonly at the type level. type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> } recurses into each property.

What error signals recursion that doesn't terminate at the type level?

  • Maximum call stack exceeded
  • Type instantiation is excessively deep and possibly infinite
  • Out of memory
  • Cannot find name

Answer: Type instantiation is excessively deep and possibly infinite. TypeScript caps recursion depth (~50) and reports this when a type expands without shrinking.

Why must 'typeof json === object' be combined with a null check in a JSON walker?

  • null is a number
  • objects are null
  • strings are objects
  • typeof null is 'object'

Answer: typeof null is 'object'. typeof null === 'object', so check json !== null before recursing into objects.

What does every recursive traversal need to terminate?

  • A loop counter
  • A base case - empty children or a null next
  • A generic parameter
  • A try/catch

Answer: A base case - empty children or a null next. Without a base case (empty children array, null next) recursion never stops.

How do you avoid infinite loops on data with cyclic references?

  • Use a smaller array
  • Freeze the object
  • Track visited nodes, e.g. with a Set
  • Increase the stack size

Answer: Track visited nodes, e.g. with a Set. A visited Set prevents revisiting a node that points back to an ancestor.

Continue this course

Frequently asked questions

What is a recursive type?

A recursive type is one whose definition refers to itself, directly or indirectly. The JSON type is the classic example: a Json value can be a primitive, OR an array of Json, OR an object whose values are Json — so the type name appears inside its own definition. Recursive types let you describe data of unbounded depth, such as trees, linked lists, nested menus, file systems, and arbitrary JSON.

Can both interfaces and type aliases be recursive?

Yes. An interface can reference itself (interface TreeNode { children: TreeNode[] }) and a type alias can too (type Json = ... | Json[] | ...). Historically interfaces handled recursion more smoothly, but modern TypeScript supports recursive type aliases well, including recursive conditional and mapped types. Choose based on the usual type-vs-interface guidance; recursion works for both.

Why do I get 'Type instantiation is excessively deep and possibly infinite'?

TypeScript caps how deeply it will expand a recursive type (around 50 levels) to keep type-checking from running forever. If a recursive type feeds into itself without a shrinking base case — or you nest data extremely deeply — the checker hits that limit and reports this error. The fix is to ensure recursion terminates: add a base case, bound the depth, or simplify the type so each step makes progress toward a non-recursive result.

How do I write a DeepReadonly type?

DeepReadonly is a recursive mapped type: type DeepReadonly<T> = { readonly [K in keyof T]: DeepReadonly<T[K]> }. For each property it makes the property readonly AND applies DeepReadonly to the property's own type, so the readonly-ness propagates all the way down. The runtime counterpart is a recursive deepFreeze that freezes nested objects before freezing the parent — exactly the pattern in this lesson's worked example.