Recursive Type Aliases in TypeScript: Building Deep Validation Types, JSON Schema Parsers, and Infinite-Scroll State Managers

TypeScript's type system can describe more than the shape of a single object — it can describe shapes that contain themselves. A comment thread that has replies, which have replies, which have replies. A JSON value that can be an object whose values are also JSON values. A paginated list whose "next page" state looks exactly like the state that produced it. All three are the same underlying problem: self-referential data, and TypeScript solves it with recursive type aliases.

This guide walks through what recursive type aliases are, why the compiler treats them differently from recursive interfaces, and then builds three production-grade patterns with them: a deep nested validation type, a type-safe JSON Schema parser, and an infinite-scroll state manager. Each section includes working code, the reasoning behind the design, and the trade-offs you should know about before shipping the pattern to a real codebase.

What Is a Recursive Type Alias?

A type alias becomes recursive when it refers to itself somewhere inside its own definition. TypeScript has supported this for object-shaped aliases for a long time, but it was TypeScript 4.1 that added support for recursive conditional types — aliases that call themselves inside a Type extends X ? Y : Z branch. That single addition is what makes almost every advanced recursive pattern (deep partials, deep validation, recursive parsers) possible, because it lets a type alias branch and recurse in the same step.

A minimal example:

type Json =
  | string
  | number
  | boolean
  | null
  | Json[]
  | { [key: string]: Json };

Json references itself twice — once as an array member, once as a value inside a mapped object. The compiler resolves this lazily: it doesn't try to "fully expand" Json into an infinitely long type. Instead, it re-evaluates the alias each time it needs to check a value against it, which is what makes recursion tractable at all.

The official TypeScript Handbook covers the mechanics this pattern depends on — conditional branching and the infer keyword for capturing sub-parts of a type — in its Conditional Types documentation, and the object-reshaping half of the puzzle in its Mapped Types documentation. Both pages are worth bookmarking before you write your own recursive alias, since almost every pattern below is a variation on "conditional branch, mapped reshape, recurse."

There's a related, more advanced technique worth knowing about by name: template literal types, which apply this same recursive branching to string types instead of object types — useful for things like typed route parsers. We covered that pattern in depth in Template Literal Types in TypeScript: Build a Type-Safe Router & Parser, and it's a natural next read once you're comfortable with the recursive object patterns below.

Pattern 1: Deep Nested Validation Types

A common real-world need is validating a deeply nested object — a form, a config file, an API payload — and having the type of the validation result mirror the shape of the input, all the way down. Instead of hand-writing a validation-result interface for every nested object, a single recursive type alias can generate it.

Step 1 — Define a recursive "Deep Validation Result" type

type ValidationResult<T> = T extends (infer U)[]
  ? { valid: boolean; errors: string[]; items: ValidationResult<U>[] }
  : T extends object
    ? { valid: boolean; errors: string[]; fields: { [K in keyof T]: ValidationResult<T[K]> } }
    : { valid: boolean; errors: string[] };

Reading this branch by branch:

  • If T is an array, we validate each element and recurse into ValidationResult<U> for the element type, giving us an items array of matching shape.
  • If T is an object (but not an array — the array branch is checked first), we recurse into every field with a mapped type, producing a fields object that mirrors the input's structure.
  • Otherwise, T is a primitive, and we bottom out with a flat { valid, errors } shape.

Step 2 — Apply it to a real, nested shape

interface Address {
  street: string;
  city: string;
  zip: string;
}

interface Order {
  id: number;
  shippingAddress: Address;
  items: { sku: string; quantity: number }[];
}

type OrderValidation = ValidationResult<Order>;
/*
{
  valid: boolean;
  errors: string[];
  fields: {
    id: { valid: boolean; errors: string[] };
    shippingAddress: {
      valid: boolean;
      errors: string[];
      fields: {
        street: { valid: boolean; errors: string[] };
        city: { valid: boolean; errors: string[] };
        zip: { valid: boolean; errors: string[] };
      };
    };
    items: {
      valid: boolean;
      errors: string[];
      items: {
        valid: boolean;
        errors: string[];
        fields: {
          sku: { valid: boolean; errors: string[] };
          quantity: { valid: boolean; errors: string[] };
        };
      }[];
    };
  };
}
*/

Every nested object and array in Order now has a matching validation-result shape, generated automatically. Add a field to Order, and OrderValidation updates itself — no manual maintenance required.

Step 3 — Pair it with a runtime validator

A recursive type alias only constrains what the compiler accepts; it doesn't check anything at runtime. You still need an actual validation function that walks the object and populates this shape:

function validateDeep<T extends object>(value: T, rules: Partial<{ [K in keyof T]: (v: T[K]) => string[] }>): ValidationResult<T> {
  const fields: any = {};
  let valid = true;

  for (const key in value) {
    const fieldValue = value[key];
    const rule = rules[key];
    const fieldErrors = rule ? rule(fieldValue) : [];

    if (Array.isArray(fieldValue)) {
      fields[key] = {
        valid: fieldErrors.length === 0,
        errors: fieldErrors,
        items: fieldValue.map((item) =>
          typeof item === "object" && item !== null
            ? validateDeep(item, {})
            : { valid: true, errors: [] }
        ),
      };
    } else if (typeof fieldValue === "object" && fieldValue !== null) {
      fields[key] = validateDeep(fieldValue, {});
    } else {
      fields[key] = { valid: fieldErrors.length === 0, errors: fieldErrors };
    }

    if (fieldErrors.length > 0) valid = false;
  }

  return { valid, errors: [], fields } as ValidationResult<T>;
}

This is the pairing the TypeScript team recommends throughout their documentation on advanced types: the type system describes the contract, and a matching runtime function enforces it. Neither one replaces the other.

Pattern 2: A Type-Safe JSON Schema Parser

JSON Schema is a vocabulary for describing the shape of JSON documents — and because a schema can describe nested objects, arrays of objects, and arrays of arrays, converting a JSON Schema definition into a matching TypeScript type is inherently a recursive problem.

Step 1 — Model a minimal JSON Schema shape

We won't implement the full JSON Schema specification (it's large), but we can cover the core keywords — type, properties, items, and required — which is enough to demonstrate the recursive mechanics that a full implementation would extend.

type JSONSchema =
  | { type: "string" }
  | { type: "number" }
  | { type: "boolean" }
  | { type: "null" }
  | { type: "array"; items: JSONSchema }
  | { type: "object"; properties: Record<string, JSONSchema>; required?: string[] };

Step 2 — Infer a TypeScript type from a schema, recursively

This is where the recursive conditional type does the real work — translating each schema keyword into its TypeScript equivalent, and recursing into items and properties wherever they appear.

type FromSchema<S extends JSONSchema> = S extends { type: "string" }
  ? string
  : S extends { type: "number" }
    ? number
    : S extends { type: "boolean" }
      ? boolean
      : S extends { type: "null" }
        ? null
        : S extends { type: "array"; items: infer I extends JSONSchema }
          ? FromSchema<I>[]
          : S extends { type: "object"; properties: infer P; required?: infer R }
            ? P extends Record<string, JSONSchema>
              ? {
                  [K in keyof P as K extends (R extends readonly string[] ? R[number] : never)
                    ? K
                    : never]: FromSchema<P[K]>;
                } & {
                  [K in keyof P as K extends (R extends readonly string[] ? R[number] : never)
                    ? never
                    : K]?: FromSchema<P[K]>;
                }
              : never
            : never;

The dense part is the object branch: it splits properties into two mapped types using key remapping (as) — one for keys listed in required (kept mandatory) and one for the rest (marked optional with ?), then intersects them back together. Everything else is a straightforward one-to-one mapping from a JSON Schema primitive keyword to its TypeScript equivalent, recursing wherever a nested schema appears.

Step 3 — Use it against a real schema

const userSchema = {
  type: "object",
  properties: {
    id: { type: "number" },
    name: { type: "string" },
    tags: { type: "array", items: { type: "string" } },
    address: {
      type: "object",
      properties: {
        city: { type: "string" },
        zip: { type: "string" },
      },
      required: ["city"],
    },
  },
  required: ["id", "name"],
} as const satisfies JSONSchema;

type User = FromSchema<typeof userSchema>;
/*
{
  id: number;
  name: string;
  address?: {
    city: string;
    zip?: string;
  };
  tags?: string[];
}
*/

Notice that id and name are required (matching the top-level required array) while address and tags are optional, and inside address, city is required while zip is optional — the recursion correctly applies required at every nesting level independently.

Why not just use a validation library?

For production systems, you generally want both: a schema-driven runtime validator (so malformed data is rejected at your trust boundary) and a compile-time type derived from the same source of truth (so your application code can't misuse the data even before it's validated). This is exactly the approach taken by schema libraries like Zod, whose official documentation shows how z.infer<typeof schema> derives a static type from a runtime schema — and, notably, how deeply nested or self-referential schemas require z.lazy() specifically because TypeScript can't eagerly resolve a type that refers to itself without a level of indirection. The FromSchema type above is doing, at the type level, the same job that z.infer does at the schema level — which is why understanding recursive type aliases makes reading (and debugging) validation-library type definitions dramatically easier.

Pattern 3: An Infinite-Scroll State Manager

Infinite scroll looks like a UI problem, but the state behind it is a recursive data structure: each "page" of results points to the next page's loading state, which — once resolved — points to the page after that. Modeling this with a recursive type alias lets the compiler guarantee that a component can never accidentally render a "next page" that doesn't have a proper loading/error/success shape, no matter how many pages deep the user has scrolled.

Step 1 — Define the recursive page-state alias

type PageState<Item> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "error"; message: string }
  | {
      status: "success";
      items: Item[];
      cursor: string | null;
      next: PageState<Item>;
    };

The next field on a successful page is itself a PageState<Item> — meaning a fully loaded scroll history is a chain of success nodes terminated by an idle (not yet requested) node at the tail.

Step 2 — Walk the chain with recursive helper types

Because the state is a linked structure, it's useful to have type-level helpers that answer questions like "what does the flattened list of items look like across every loaded page?"

type FlattenItems<S extends PageState<any>> = S extends { status: "success"; items: infer I; next: infer N }
  ? N extends PageState<any>
    ? I extends unknown[]
      ? [...I, ...FlattenItems<N>]
      : never
    : never
  : [];

This recurses through the next chain, concatenating each page's items tuple until it reaches a non-success node (idle, loading, or error), at which point it stops and returns an empty tuple.

Step 3 — Drive real reducer logic from the same shape

type Action<Item> =
  | { type: "FETCH_START" }
  | { type: "FETCH_SUCCESS"; items: Item[]; cursor: string | null }
  | { type: "FETCH_ERROR"; message: string };

function pageReducer<Item>(state: PageState<Item>, action: Action<Item>): PageState<Item> {
  switch (action.type) {
    case "FETCH_START":
      return { status: "loading" };
    case "FETCH_ERROR":
      return { status: "error", message: action.message };
    case "FETCH_SUCCESS":
      return {
        status: "success",
        items: action.items,
        cursor: action.cursor,
        next: { status: "idle" },
      };
    default:
      return state;
  }
}

function appendNextPage<Item>(state: PageState<Item>, action: Action<Item>): PageState<Item> {
  if (state.status !== "success") return pageReducer(state, action);
  return { ...state, next: pageReducer(state.next, action) };
}

appendNextPage recurses down the chain until it finds the node that's actually idle or loading, and applies the reducer there — mirroring, at runtime, the exact recursive structure the type alias describes. A component reading this state can safely destructure state.next.next.items (with appropriate status narrowing at each level) and know the compiler will catch any step where that assumption breaks.

A practical caveat for infinite lists

A linked PageState chain is great for correctness but awkward for a component that just wants "all items loaded so far" without walking pointers. In production, most teams keep the recursive PageState as the source of truth type for reasoning about transitions and correctness, while flattening it into a plain Item[] (using something like FlattenItems at the type level and a simple loop at runtime) for what actually gets rendered. That combination gives you recursion's correctness guarantees without paying its ergonomics cost inside JSX.

Recursion Limits and Compiler Performance

Recursive type aliases are not free, and TypeScript enforces real limits to protect the compiler:

  • Instantiation depth limits. TypeScript caps how deeply a recursive type can expand before it errors with "Type instantiation is excessively deep and possibly infinite." Very deeply nested real-world objects (deeply nested config trees, for instance) can hit this ceiling.
  • Type-checking time. Every recursive alias adds work to tsc. A handful of recursive aliases is unnoticeable; dozens of them, applied across a large codebase, can measurably slow down tsc --noEmit and editor responsiveness. Measure before rolling a pattern out broadly.
  • Distributive quirks. Conditional types distribute over unions by default unless the checked type is wrapped in a tuple ([T] extends [U]). This matters more than usual in recursive aliases, since an unintended distribution can silently produce a different (and much larger) type than you expect.

None of this means avoid recursive type aliases — it means isolate them. Keep them in a dedicated types/ file, comment the non-obvious branches, and pair every recursive type with a runtime implementation that actually enforces the same rules on real data, since — as with the template literal type patterns covered previously on this site — the type system alone never validates values coming from the network, a database, or user input.

Best Practices for Production Recursive Types

  • Give recursion a base case early. Every pattern in this article terminates on a primitive, an idle status, or an empty tuple. A recursive alias without a clear exit condition is the most common source of "excessively deep" compiler errors.
  • Prefer infer over manual indexing when capturing sub-parts of a type mid-recursion — it keeps each branch readable and matches the idiom used throughout the official TypeScript documentation.
  • Write type-only tests. Tools like tsd or expect-type let you assert that FromSchema<typeof userSchema> resolves to exactly the shape you expect, the same way you'd unit-test runtime code.
  • Never let a recursive type substitute for runtime validation. Whether you're validating a form, parsing a schema, or paginating a list, the compiler check and the runtime check are two separate guarantees — you need both.

Conclusion

Recursive type aliases are the tool that lets TypeScript describe data that contains more of itself — nested objects of arbitrary depth, JSON Schema definitions, and paginated state chains all being instances of the same underlying idea. The mechanics are consistent across every use case: branch on the shape of the input with a conditional type, capture the pieces you need with infer, reshape them with a mapped type, and recurse on whatever's left. Once that loop is second nature, deep validation types, schema-to-type parsers, and infinite-scroll reducers stop looking like three different problems and start looking like the same three-step pattern, applied three times.

Frequently Asked Questions

Are recursive type aliases the same as recursive interfaces? Interfaces have always supported self-reference for object shapes. What TypeScript 4.1 added specifically was support for recursion inside conditional type aliases — the T extends X ? Y : Z form — which is what makes branching, self-referential logic like the patterns above possible for type aliases, not just interfaces.

Why does my recursive type throw "Type instantiation is excessively deep"? This means the compiler hit its recursion-depth safety limit while trying to expand the type, usually because the input is deeply nested or the recursive branch doesn't reduce toward a base case quickly enough. Check that every recursive call operates on a strictly smaller piece of the input (a shorter string, a shallower object) than the call before it.

Do I still need a validation library if I use recursive types? Yes. Recursive type aliases only constrain what compiles — they run entirely at compile time and disappear from the emitted JavaScript. Values arriving from a network request, a form, or a database still need runtime validation at your application's trust boundary.

Can recursive type aliases slow down my build? They can, if used heavily across a large codebase. Isolate recursive aliases into a dedicated file, keep base cases tight, and periodically check tsc --noEmit timing if you introduce several of them.

Related reading: Template Literal Types in TypeScript: Build a Type-Safe Router & Parser · Server-Driven UI in React: A Type-Safe Architecture Guide