TypeScriptRecursive Types

Recursive Types

Recursive types are types that reference themselves in their own definition. They are essential for modeling hierarchical or nested data structures like trees, file systems, nested menus, and JSON values — anywhere data can contain more data of the same shape.

TypeScript has supported recursive type aliases since version 3.7, and the compiler handles them efficiently for most common patterns.

Why Recursive Types?

Many real-world data structures are inherently recursive. A folder can contain files or more folders. A comment can have replies that are also comments. A JSON value can be a number, string, boolean, array of JSON values, or an object whose values are JSON values.

Without recursive types you would have to pick an arbitrary nesting depth and duplicate your type definitions, which is both fragile and inaccurate.

The JSON Type

The canonical example of a recursive type is a complete definition of JSON-compatible values:

TS
type JSONPrimitive = string | number | boolean | null;

type JSONValue =
  | JSONPrimitive
  | JSONValue[]
  | { [key: string]: JSONValue };

// All of these are valid JSONValue:
const a: JSONValue = 42;
const b: JSONValue = "hello";
const c: JSONValue = [1, 2, 3];
const d: JSONValue = { name: "Alice", scores: [10, 20] };
const e: JSONValue = { nested: { deep: { value: true } } };
Note
The type alias JSONValue references itself inside its own definition. TypeScript resolves this lazily, so there is no infinite loop at compile time.
Tree Nodes

A binary tree is another classic recursive structure. Each node holds a value and optionally has left and right child nodes of the same type:

TS
interface TreeNode<T> {
  value: T;
  left?: TreeNode<T>;
  right?: TreeNode<T>;
}

const tree: TreeNode<number> = {
  value: 10,
  left: {
    value: 5,
    left: { value: 2 },
    right: { value: 7 },
  },
  right: {
    value: 15,
    right: { value: 20 },
  },
};

function sum(node: TreeNode<number> | undefined): number {
  if (!node) return 0;
  return node.value + sum(node.left) + sum(node.right);
}

console.log(sum(tree)); // 59
Tip
Generic recursive types like TreeNode<T> are extremely reusable. The same interface works for trees of strings, objects, or any other type.
Nested Menu Structures

Navigation menus often have unlimited nesting depth. A recursive type models this perfectly:

TS
interface MenuItem {
  label: string;
  href?: string;
  children?: MenuItem[];
}

const nav: MenuItem[] = [
  {
    label: "Products",
    children: [
      { label: "Web", href: "/web" },
      {
        label: "Mobile",
        children: [
          { label: "iOS", href: "/ios" },
          { label: "Android", href: "/android" },
        ],
      },
    ],
  },
  { label: "About", href: "/about" },
];

function flattenMenu(items: MenuItem[]): string[] {
  return items.flatMap((item) => [
    item.label,
    ...(item.children ? flattenMenu(item.children) : []),
  ]);
}

console.log(flattenMenu(nav));
// ["Products", "Web", "Mobile", "iOS", "Android", "About"]
File System Representation

A file system has directories that can contain files or other directories. Union types combined with recursion model this precisely:

TS
interface File {
  kind: "file";
  name: string;
  size: number;
}

interface Directory {
  kind: "directory";
  name: string;
  children: (File | Directory)[];
}

type FSEntry = File | Directory;

const root: Directory = {
  kind: "directory",
  name: "/",
  children: [
    { kind: "file", name: "README.md", size: 1024 },
    {
      kind: "directory",
      name: "src",
      children: [
        { kind: "file", name: "index.ts", size: 512 },
        { kind: "file", name: "utils.ts", size: 768 },
      ],
    },
  ],
};

function totalSize(entry: FSEntry): number {
  if (entry.kind === "file") return entry.size;
  return entry.children.reduce((acc, child) => acc + totalSize(child), 0);
}

console.log(totalSize(root)); // 2304
Deeply Nested Objects

Sometimes you need a type for objects of unknown nesting depth where every leaf is the same type. The DeepNested pattern handles this:

TS
type DeepNested<T> = T | { [key: string]: DeepNested<T> };

const config: DeepNested<string> = {
  database: {
    host: "localhost",
    credentials: {
      user: "admin",
      password: "secret",
    },
  },
  app: {
    name: "MyApp",
  },
};

// Flatten nested config into dot-notation keys
function flatten(
  obj: DeepNested<string>,
  prefix = ""
): Record<string, string> {
  if (typeof obj === "string") return { [prefix]: obj };
  return Object.entries(obj).reduce((acc, [key, val]) => {
    const fullKey = prefix ? `${prefix}.${key}` : key;
    return { ...acc, ...flatten(val, fullKey) };
  }, {} as Record<string, string>);
}

console.log(flatten(config));
// { "database.host": "localhost", "database.credentials.user": "admin", ... }
Recursive Conditional Types

Recursive conditional types let you traverse type structures at the type level. A common use case is deeply unwrapping a Promise chain:

TS
type DeepAwaited<T> =
  T extends Promise<infer U>
    ? DeepAwaited<U>
    : T;

type A = DeepAwaited<Promise<Promise<Promise<string>>>>;
// string

type B = DeepAwaited<Promise<number>>;
// number

type C = DeepAwaited<boolean>;
// boolean
Note
TypeScript 4.1+ supports tail-recursive conditional types. Earlier versions would error with "Type alias circularly references itself" for some patterns.
Deep Readonly

A recursive mapped type that makes every property at every level of nesting read-only:

TS
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? DeepReadonly<T[K]>
    : T[K];
};

interface Config {
  server: {
    host: string;
    port: number;
    tls: {
      enabled: boolean;
      cert: string;
    };
  };
  debug: boolean;
}

type FrozenConfig = DeepReadonly<Config>;

declare const cfg: FrozenConfig;
// cfg.server.host = "x";         // Error: read-only property
// cfg.server.tls.enabled = true; // Error: deeply read-only
Deep Partial

The built-in Partial<T> only makes the top level optional. A recursive version makes every nested property optional too, which is useful for deep merge utilities:

TS
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

interface AppSettings {
  theme: {
    colors: {
      primary: string;
      secondary: string;
    };
    fontSize: number;
  };
  language: string;
}

type SettingsPatch = DeepPartial<AppSettings>;

function mergeSettings(
  base: AppSettings,
  patch: SettingsPatch
): AppSettings {
  return {
    ...base,
    ...patch,
    theme: {
      ...base.theme,
      ...(patch.theme ?? {}),
      colors: {
        ...base.theme.colors,
        ...(patch.theme?.colors ?? {}),
      },
    },
  };
}
Mutual Recursion

Two types can reference each other — this is called mutual recursion. TypeScript handles this as long as both types are declared before use:

TS
interface Literal {
  kind: "literal";
  value: number;
}

interface BinaryOp {
  kind: "binary";
  op: "+" | "-" | "*" | "/";
  left: Expression;
  right: Expression;
}

type Expression = Literal | BinaryOp;

function evaluate(expr: Expression): number {
  if (expr.kind === "literal") return expr.value;
  const l = evaluate(expr.left);
  const r = evaluate(expr.right);
  switch (expr.op) {
    case "+": return l + r;
    case "-": return l - r;
    case "*": return l * r;
    case "/": return l / r;
  }
}

const expr: Expression = {
  kind: "binary",
  op: "+",
  left: { kind: "literal", value: 3 },
  right: {
    kind: "binary",
    op: "*",
    left: { kind: "literal", value: 4 },
    right: { kind: "literal", value: 5 },
  },
};

console.log(evaluate(expr)); // 23
Linked List

TS
interface ListNode<T> {
  value: T;
  next: ListNode<T> | null;
}

function fromArray<T>(arr: T[]): ListNode<T> | null {
  if (arr.length === 0) return null;
  const [head, ...tail] = arr;
  return { value: head, next: fromArray(tail) };
}

function toArray<T>(node: ListNode<T> | null): T[] {
  const result: T[] = [];
  let current = node;
  while (current !== null) {
    result.push(current.value);
    current = current.next;
  }
  return result;
}

const list = fromArray([1, 2, 3, 4, 5]);
console.log(toArray(list)); // [1, 2, 3, 4, 5]
Performance Considerations
  • Prefer interface over type alias for recursive object shapes — interfaces are lazily evaluated.

  • Avoid recursive types in hot paths computed thousands of times across a large codebase.

  • TypeScript imposes a depth limit on recursive conditional types (around 100 levels).

  • Use tsc --diagnostics to measure type-checking time if you suspect a performance problem.

  • When a recursive type only needs a few levels, consider expanding it manually instead.

Warning
If the TypeScript compiler shows "Type alias circularly references itself", switch from a type alias to an interface, or add one layer of indirection (wrap in an object or array).
Quick Reference

Pattern

Use Case

Notes

interface T { children?: T[] }

Trees, menus, file system

Most common pattern

type T = Primitive | T[]

Arrays of arrays (JSON arrays)

Use type alias

type T = V | { [k: string]: T }

Config objects, JSON objects

Index signature recursion

type D<T> = T extends P<infer U> ? D<U> : T

Unwrap nested generics

Requires TS 4.1+ for tail-rec

type DR<T> = { readonly [K in keyof T]: DR<T[K]> }

Deep Readonly / Deep Partial

Mapped + recursive

Success
You now understand recursive types. Start with the JSONValue pattern, then move to generic tree nodes, and finally explore recursive conditional types when you need type-level traversal.