Skip to content
JL

Home

About

Blog

Contact

Shop

Portfolio

Privacy

TOS

Click to navigate

  1. Home
  2. Joshua R. Lehman's Blog
  3. Recursive Types in TypeScript

Table of contents

  • Share on X
  • Discuss on X

Related Articles

Template Literal Types in TypeScript
TypeScript
9m
Aug 2, 2026

Template Literal Types in TypeScript

Template literal types bring JavaScript's template literal syntax to the type level. They let you construct new string literal types by combining existing ones — the same way a template literal assembles a string at runtime, but resolved entirely at compile time.

#Template Literal Types#String Manipulation Types+5
Utility Types Part 2: Readonly and Record
TypeScript
10m
Aug 23, 2026

Utility Types Part 2: Readonly and Record

Two utility types that occupy opposite ends of the structural transformation space: Readonly locks an object's properties against mutation at the type level, while Record builds typed dictionary structures from a set of keys and a value type. Neither modifies optionality — they operate on shape and mutability instead.

#Utility Types#Readonly+5
Utility Types Part 1: Partial and Required
TypeScript
9m
Aug 16, 2026

Utility Types Part 1: Partial and Required

TypeScript ships with a set of built-in generic types — called utility types — that perform common type transformations on existing types. Rather than manually rewriting every property of an object type, you pass your type to a utility type and receive a transformed version. Partial and Required are the two complementary utility types that control whether every property in an object is optional or required.

#Utility Types#Partial+5
Ask me anything! 💬

© Joshua R. Lehman

Full Stack Developer

Crafted with passion • Built with modern web technologies

2026 • All rights reserved

Contents

  • What Makes a Type Recursive
  • The JSON Value Problem
  • Tree and Node Structures
  • Linked Lists and Chains
  • Recursive Conditional Types
  • Depth Limits and Practical Constraints
  • Common Patterns and Pitfalls
  • What Is Next
  • Key Takeaways
TypeScript

Recursive Types in TypeScript

August 9, 2026•9 min read
Joshua R. Lehman
Joshua R. Lehman
Author
TypeScript recursive type definition showing a self-referencing tree node structure
Recursive Types in TypeScript

Some of the most important data structures in programming are self-referential. A file system entry is either a file or a directory containing more file system entries. A JSON value is a primitive, an array of JSON values, or an object whose property values are themselves JSON values. A binary tree node holds a value and optional left and right children — which are also binary tree nodes.

Expressing these structures accurately in TypeScript requires types that reference themselves in their own definition. These are recursive types, and they are one of the features that distinguish TypeScript's type system from simpler annotation approaches. A recursive type alias allows you to write a single definition that captures arbitrarily deep nesting without repeating yourself or resorting to any.

What Makes a Type Recursive

A recursive type is a type alias that appears in its own definition. TypeScript has supported recursive type aliases since version 3.7, when the compiler was updated to handle direct self-reference in type alias object properties without needing an intermediate interface.

The basic form looks like this:

type NestedNumber = number | NestedNumber[];

NestedNumber represents either a number or an array of NestedNumber values. A value of type NestedNumber can be 42, [1, 2, 3], [[1, 2], [3, [4, 5]]], or any arbitrary nesting depth.

The recursion works because TypeScript evaluates type aliases lazily when they appear within object or array structures. The type checker does not need to fully expand the alias before using it — it only expands as deeply as the actual value requires during type checking.

Recursive Type Aliases vs Interfaces

Before TypeScript 3.7, recursive self-references in type aliases required an intermediate interface. type Tree = { value: number; children: Tree[] } would cause an error. The pattern was interface TreeNode { value: number; children: TreeNode[] }, then type Tree = TreeNode. Modern TypeScript (3.7+) allows direct recursion in type aliases when the self-reference appears within an array or object property — not at the top level of the alias. type X = X is still invalid; type X = { next: X } is fine.

The JSON Value Problem

The classic motivating example for recursive types is JSON.parse. The return type of JSON.parse in the TypeScript standard library is any, because representing arbitrary JSON accurately requires a recursive type — something that was not supported until TypeScript 3.7.

A proper JsonValue type captures the full structure of valid JSON:

type JsonPrimitive = string | number | boolean | null;
 
type JsonObject = { [key: string]: JsonValue };
 
type JsonArray = JsonValue[];
 
type JsonValue = JsonPrimitive | JsonObject | JsonArray;

JsonValue references JsonObject and JsonArray, both of which reference JsonValue — forming a mutually recursive cycle. TypeScript resolves this correctly.

With this type, you can write type-safe JSON-handling utilities:

function getProperty(obj: JsonObject, key: string): JsonValue | undefined {
  return obj[key];
}
 
function isJsonObject(value: JsonValue): value is JsonObject {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}
 
function isJsonArray(value: JsonValue): value is JsonArray {
  return Array.isArray(value);
}

These functions are precisely typed without any. The recursive definition ensures that a JsonValue at any depth of nesting is still a JsonValue.

Use JsonValue Instead of any for Parsed Data

When working with data from JSON.parse, fetch responses, or external APIs, define a JsonValue type and cast to it rather than using any. This preserves type checking for all subsequent operations on the parsed data. You will still need type narrowing to work with specific structures, but you eliminate the type-safety gap that any creates. Many codebases centralise this definition in a types/json.ts utility file shared across the project.

Tree and Node Structures

Tree structures are the other canonical use case for recursive types. A binary tree node, a general-purpose tree, a DOM-like node tree, an AST (abstract syntax tree) — all follow the same pattern: a node contains data and zero or more children of the same node type.

A generic tree node:

type TreeNode<T> = {
  value: T;
  children: TreeNode<T>[];
};

This combines recursive types with generics. A TreeNode<string> is a node whose value is a string, and whose children are also TreeNode<string> values. The type scales to any depth without any additional type definitions.

A binary tree specifically:

type BinaryTreeNode<T> = {
  value: T;
  left: BinaryTreeNode<T> | null;
  right: BinaryTreeNode<T> | null;
};

You can write algorithms over these types with the same type safety you get from flat structures:

function treeDepth<T>(node: BinaryTreeNode<T> | null): number {
  if (node === null) return 0;
  return 1 + Math.max(treeDepth(node.left), treeDepth(node.right));
}
 
function mapTree<T, U>(
  node: BinaryTreeNode<T>,
  fn: (value: T) => U
): BinaryTreeNode<U> {
  return {
    value: fn(node.value),
    left: node.left ? mapTree(node.left, fn) : null,
    right: node.right ? mapTree(node.right, fn) : null,
  };
}

TypeScript infers the return types correctly because the recursive structure mirrors the recursive type definition.

A file system representation is another practical tree:

type FileEntry = {
  name: string;
  type: "file";
  size: number;
};
 
type DirectoryEntry = {
  name: string;
  type: "directory";
  children: FileSystemEntry[];
};
 
type FileSystemEntry = FileEntry | DirectoryEntry;

The children array references FileSystemEntry, which includes DirectoryEntry — a classic recursive discriminated union. Functions that traverse this structure can use the type discriminant to handle files and directories differently, with full exhaustiveness checking.

Linked Lists and Chains

A linked list is a simpler recursive structure — each node holds a value and a pointer to the next node:

type ListNode<T> = {
  value: T;
  next: ListNode<T> | null;
};

The null base case terminates the recursion at the type level, just as it terminates the traversal at runtime. A list of numbers:

const list: ListNode<number> = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: null,
    },
  },
};

TypeScript validates the entire nested structure against the recursive type definition. A mistake anywhere in the nesting — a missing property, an incorrect value type — is caught at compile time.

Linked list operations are straightforwardly typed:

function listToArray<T>(node: ListNode<T> | null): T[] {
  const result: T[] = [];
  let current = node;
  while (current !== null) {
    result.push(current.value);
    current = current.next;
  }
  return result;
}
 
function arrayToList<T>(arr: T[]): ListNode<T> | null {
  if (arr.length === 0) return null;
  return {
    value: arr[0],
    next: arrayToList(arr.slice(1)),
  };
}

Recursive Conditional Types

Recursive types become significantly more powerful when combined with conditional types and infer. You can write type-level computations that recurse over the structure of a type.

A common example is DeepReadonly — making every property at every nesting level readonly:

type DeepReadonly<T> = T extends (infer Item)[]
  ? ReadonlyArray<DeepReadonly<Item>>
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

DeepReadonly<T> works by checking what T is. If it is an array, the result is a ReadonlyArray of recursively-deep-readonly items. If it is an object, the result is an object with all properties marked readonly, and each property value recursively processed. Primitive types pass through unchanged.

Similarly, DeepPartial makes every property at every level optional:

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

These recursive utility types are extremely useful for application-level type definitions — configuration objects, state trees, and nested form models often benefit from deep transformations.

Recursive Conditional Types Require Careful Termination

A recursive conditional type must have a base case — a branch of the conditional that returns a non-recursive result. Without a base case, TypeScript will detect infinite recursion and produce an error. The primitive check at the end (T extends object ? ... : T) is the base case: when T is a string, number, boolean, or other primitive, the recursion stops and returns T unchanged. Always verify your base case covers all non-object types that should terminate the recursion.

Depth Limits and Practical Constraints

TypeScript enforces an internal depth limit on recursive type instantiation to prevent infinite expansion during type checking. This limit is approximately 100 levels deep in most contexts, though the exact threshold varies by TypeScript version and the complexity of the type.

In practice, this limit is rarely encountered with data structure types — real JSON, trees, and linked lists almost never exceed 100 levels of nesting. It is more likely to become an issue with recursive conditional types that perform computations on type-level tuples or strings, where the recursion count can grow quickly.

When you encounter a "Type instantiation is excessively deep and possibly infinite" error, the usual causes are:

  • A recursive conditional type with no base case or a base case that is never reached for the input type
  • A type that performs arithmetic or string manipulation operations through recursion (counting, reversing, etc.)
  • Circular type definitions where neither branch terminates the recursion

For deep recursion scenarios, one workaround is distributing the recursion across an intermediate tuple or array accumulator, which reduces the depth per step. TypeScript 4.5+ handles tail-recursive conditional types more efficiently, so updating the TypeScript version often helps when depth limits are hit.

Use Interfaces for Mutually Recursive Types When Performance Matters

When defining mutually recursive types that will be used heavily throughout a codebase, prefer interfaces over type aliases. TypeScript caches interface shapes more aggressively than type alias expansions, which can improve compilation performance for complex recursive structures used across many files. The trade-off is that interfaces have slightly different merging behaviour — but for most recursive data structure definitions, interfaces are the better choice at scale.

Common Patterns and Pitfalls

Top-level self-reference is not allowed. type X = X is circular without indirection and TypeScript rejects it. The self-reference must appear inside an object property, array, or conditional branch — not at the top level of the alias definition.

Mutual recursion is valid. Two types can reference each other:

type Even = 0 | { value: number; next: Odd };
type Odd = { value: number; next: Even };

TypeScript resolves mutually recursive types correctly as long as neither expands infinitely during checking.

Discriminated union recursion. Combining recursion with discriminated unions is a common and effective pattern — each variant of the union carries a type discriminant, and the recursive case references the union rather than the specific variant. This is the pattern used in AST definitions and expression trees:

type Expr =
  | { kind: "num"; value: number }
  | { kind: "add"; left: Expr; right: Expr }
  | { kind: "mul"; left: Expr; right: Expr }
  | { kind: "neg"; operand: Expr };

The kind field allows type-safe exhaustive switches over the expression structure at any depth.

Avoid accidentally wide recursive types. If the recursion base case includes any or an overly broad type, the entire recursive structure becomes wide. Keep the base case precise — typically the union of primitive types your structure can hold at its leaves.

What Is Next

Recursive types allow TypeScript to model arbitrarily nested data structures with full compile-time accuracy. The next post begins a series on TypeScript's built-in utility types — pre-defined generic types in the standard library that solve common type transformation problems. The first two are Partial and Required, which toggle property optionality across an entire object type without manually rewriting every property.

Key Takeaways

  • A recursive type alias references itself within an object property, array, or conditional branch — not at the top level; TypeScript 3.7+ supports this natively
  • The JsonValue pattern — type JsonValue = string | number | boolean | null | JsonObject | JsonArray — is the canonical recursive type for representing arbitrary JSON without any
  • Tree, linked list, and file system structures are natural applications of recursive types; combine with generics to make them reusable across value types
  • Recursive conditional types like DeepReadonly<T> and DeepPartial<T> apply transformations to every level of an object's nesting; they require a primitive base case to terminate recursion
  • TypeScript enforces a depth limit of approximately 100 levels on recursive type instantiation; this limit is rarely a problem for data structures but can affect complex recursive computations