⚡IntermediateAdvanced Type System

Recursive Types

Model trees, nested structures, and linked lists at the type level using self-referential type aliases and recursive conditional types.

Overview

A **recursive type** is a type alias that references itself. This lets you describe data structures that are nested to an arbitrary depth - trees, JSON, linked lists, file system paths. TypeScript 3.7+ supports recursive type aliases directly: `type Tree<T> = { value: T; children: Tree<T>[] }`. Before that, you needed an interface to get recursion (interfaces have always supported self-reference via structural typing). Recursive **conditional types** go further: they apply conditional logic at each level of nesting. `DeepReadonly<T>` is the classic example - if a property is itself an object, recurse into it; if it's a primitive, apply readonly and stop. The key constraint is that TypeScript must be able to determine the recursion terminates. Most practical patterns (deepening an object, unwrapping arrays, traversing a tree) do terminate naturally. TypeScript enforces this and will error if it detects a truly infinite expansion with no base case.

Key Points

  • •Recursive type alias: `type Node<T> = { value: T; next: Node<T> | null }` - refers to itself
  • •Works for object types, arrays, tuples, and union members
  • •Recursive conditional types: apply a transformation recursively until hitting a base case (primitives)
  • •`DeepReadonly<T>`, `DeepPartial<T>`, `DeepRequired<T>` are all built with recursive conditional types
  • •JSON type: `type JSON = string | number | boolean | null | JSON[] | { [key: string]: JSON }` - a real recursive type
  • •Depth-limiting: TypeScript enforces termination but has practical depth limits; very deep recursion may cause "Type instantiation is excessively deep" errors
  • •Prefer recursive interfaces over recursive type aliases when describing object shapes - interfaces are more efficient

Examples

Tree and linked list types

// Binary tree
type TreeNode<T> = {
  value: T;
  left:  TreeNode<T> | null;
  right: TreeNode<T> | null;
};

const tree: TreeNode<number> = {
  value: 1,
  left: {
    value: 2,
    left:  { value: 4, left: null, right: null },
    right: { value: 5, left: null, right: null },
  },
  right: {
    value: 3,
    left:  null,
    right: { value: 6, left: null, right: null },
  },
};

// Linked list (recursive interface  -  more efficient)
interface ListNode<T> {
  value: T;
  next: ListNode<T> | null;
}

const list: ListNode<string> = {
  value: 'first',
  next: {
    value: 'second',
    next: { value: 'third', next: null },
  },
};

// Traversal helpers (type-safe)
function treeSum(node: TreeNode<number> | null): number {
  if (!node) return 0;
  return node.value + treeSum(node.left) + treeSum(node.right);
}

console.log(treeSum(tree)); // 21

JSON type and nested data

// The famous recursive JSON type
type JSONValue =
  | string
  | number
  | boolean
  | null
  | JSONValue[]
  | { [key: string]: JSONValue };

// Safely describe any valid JSON
const config: JSONValue = {
  name: 'my-app',
  version: 1,
  settings: {
    debug: true,
    tags: ['ts', 'json'],
    nested: { deep: { deeper: null } },
  },
};

// Safe deep-access utility
function get(obj: JSONValue, path: string[]): JSONValue {
  let current = obj;
  for (const key of path) {
    if (typeof current !== 'object' || current === null || Array.isArray(current)) {
      return null;
    }
    current = (current as { [k: string]: JSONValue })[key] ?? null;
  }
  return current;
}

console.log(get(config, ['settings', 'tags'])); // ['ts', 'json']

Recursive conditional types (DeepReadonly, DeepPartial)

// DeepReadonly  -  make every nested property readonly
type DeepReadonly<T> =
  T extends (infer U)[]
    ? ReadonlyArray<DeepReadonly<U>>
    : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

// DeepPartial  -  make every nested property optional
type DeepPartial<T> =
  T extends object
    ? { [K in keyof T]?: DeepPartial<T[K]> }
    : T;

interface Config {
  server: { host: string; port: number; ssl: { enabled: boolean } };
  database: { url: string; pool: { min: number; max: number } };
}

type FrozenConfig = DeepReadonly<Config>;
// Every property at every level is readonly

type PartialConfig = DeepPartial<Config>;
// Every property at every level is optional

const patch: PartialConfig = {
  server: { port: 8080 }, // only patch port, leave the rest
};

const frozen: FrozenConfig = {
  server: { host: 'localhost', port: 3000, ssl: { enabled: true } },
  database: { url: 'postgres://...', pool: { min: 2, max: 10 } },
};

// frozen.server.port = 4000; // Error: readonly

Common Mistakes

  • "Type instantiation is excessively deep" error - TypeScript has a recursion depth limit (~100 levels). Simplify the type or add an early exit branch for large depths
  • Recursive type aliases that directly expand to themselves without an object/array wrapper cause infinite loops - always have a union base case like `| null` or `| Primitive`
  • Using recursive conditionals on union types can cause unexpected distribution - wrap in a tuple `[T] extends [object]` to prevent distribution when needed

Note

Every recursive type needs a base case - a branch that does NOT recurse. For `DeepReadonly<T>`, the base case is "T is a primitive, return T as-is". Without a base case, TypeScript either errors or enters infinite expansion. Think of it like a recursive function: always have a return path that doesn't call itself.

Exercises

Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.

Loading editor…

Completed: 0 / 3 exercises

Knowledge Check

Question 1 of 4

Why does every recursive type need a base case (non-recursive branch)?