TypeScript’s recursive types are weirdly powerful, letting you define structures that refer to themselves, which sounds like a recipe for infinite loops but actually gives you precise control over deeply nested data.

Let’s see one in action. Imagine you have a configuration object that can contain other configuration objects, or simple values.

type ConfigValue = string | number | boolean | ConfigObject;

interface ConfigObject {
  [key: string]: ConfigValue;
}

const myConfig: ConfigObject = {
  port: 8080,
  database: {
    host: "localhost",
    port: 5432,
    credentials: {
      user: "admin",
      pass: "secret123"
    }
  },
  logging: {
    level: "info",
    enabled: true
  }
};

Here, ConfigValue can be a primitive or a ConfigObject. And ConfigObject is defined as having string keys that map to ConfigValues. This creates the recursion: ConfigObject can contain ConfigObjects, which can contain ConfigObjects, and so on, to any depth. TypeScript allows this because it’s structural typing; it doesn’t try to evaluate the infinite depth upfront but rather checks if the structure could conform at any given point.

This is incredibly useful for things like representing JSON data, abstract syntax trees (ASTs) in compilers, or deeply nested configuration. The key is that the recursion has a base case – the primitive types (string | number | boolean) prevent it from being an infinite definition.

The most surprising thing about recursive types is how they enable deep modifiers. Take DeepReadonly. If you just use Readonly<T>, it only makes the top level of an object readonly.

interface User {
  name: string;
  address: {
    street: string;
    city: string;
  };
}

const user: Readonly<User> = {
  name: "Alice",
  address: {
    street: "123 Main St",
    city: "Anytown"
  }
};

// user.address.city = "Othertown"; // Error! Top-level is readonly.

// user.address.city = "Othertown"; // This would also be an error if Readonly was deep.

To make all levels readonly, you need a recursive conditional type:

type DeepReadonly<T> = T extends (infer R)[] ? DeepReadonly<R>[] : T extends Function | Date | RegExp ? T : T extends object ? { readonly [P in keyof T]: DeepReadonly<T[P]>; } : T;

interface User {
  name: string;
  address: {
    street: string;
    city: string;
    zip: number;
  };
  tags: string[];
}

const readonlyUser: DeepReadonly<User> = {
  name: "Bob",
  address: {
    street: "456 Oak Ave",
    city: "Someville",
    zip: 12345
  },
  tags: ["admin", "dev"]
};

// readonlyUser.address.city = "Newville"; // Error! Deeply readonly.
// readonlyUser.tags[0] = "user"; // Error! Deeply readonly.

Let’s break DeepReadonly<T> down:

  • T extends (infer R)[] ? DeepReadonly<R>[]: If T is an array, recursively apply DeepReadonly to its element type (R) and return a new array type with readonly elements.
  • T extends Function | Date | RegExp ? T: If T is a function, Date, or RegExp, don’t try to make it readonly. These are typically treated as immutable or their mutability is handled differently.
  • T extends object ? { readonly [P in keyof T]: DeepReadonly<T[P]>; }: If T is any other object (excluding primitives), iterate over its keys (P), and for each property, recursively call DeepReadonly on its value (T[P]). The readonly keyword is applied to the resulting mapped type.
  • : T: If T is none of the above (i.e., a primitive type like string, number, boolean), return T as is.

This recursive definition allows DeepReadonly to traverse any depth of nested objects and arrays, making every property readonly.

When you define a recursive type, TypeScript doesn’t actually calculate the full depth at compile time. Instead, it uses a technique called type instantiation and conditional types to defer the evaluation. The compiler essentially says, "If this type has a property X, and X is also an object, then X must also conform to this recursive definition." This lazy evaluation is what prevents infinite loops and allows these types to be both powerful and type-safe. The magic is that the compiler can determine that a type can be assigned to a recursive type without needing to know its exact depth, as long as the structure is consistent.

The next thing you’ll likely want to tackle is creating types that transform recursive structures, like a DeepPartial or a DeepRequired type, which are built using the same recursive conditional type patterns.

Want structured learning?

Take the full Typescript course →