TypeScript

TypeScript’s Type System as a Proof System: Type-Level Programming Beyond the Basics

Conditional types, infer, template literal parsing, recursive algorithms — and why the type checker is really a theorem prover you have been using all along.

Most TypeScript tutorials stop at generics. A few venture into conditional types, show you T extends U ? X : Y, and call it advanced. But there is an entirely different way to think about what TypeScript’s type system actually is — and once you see it that way, a whole class of problems that seemed like black magic becomes completely systematic.

The TypeScript type system is, in a rigorous sense, a logic programming language. It has variables (type parameters), functions (generic types), pattern matching (extends clauses), recursion, and — as has been formally demonstrated — it is Turing complete. When you write a complex conditional type, you are not just annotating data — you are writing a program that the compiler runs at compile time and uses the result as a proof that your runtime code is correct. That framing changes how you approach type-level problems entirely.

In this guide, we will work through the core machinery in depth: conditional types and distributivity, the infer keyword as pattern matching, template literal types as a string parsing DSL, and recursive type-level algorithms. Each section builds on the last, and every snippet is designed to be genuinely useful — not just a parlour trick.

TypeScript Is Two Languages at Once

Before anything else, it helps to make the conceptual separation explicit. When you write TypeScript, you are actually writing in two distinct languages simultaneously:

The value language

Standard JavaScript. Runs at runtime. Has variables, functions, objects, arrays. This is the code that ships to the browser or Node.js.

The type language

Runs entirely at compile time. Has its own variables (type params), functions (generics), branching (conditional types), and loops (recursion). Erased before runtime.

These two languages interact through annotations and inference, but they are semantically separate. A key insight: in the type language, there is no mutation, no side effects, and no iteration — only recursion. This makes it behave like a pure functional language, which is exactly why functional programming intuitions map onto type-level programming so well.

The type language is declarative, not imperative

You do not write “check if T is a string and if so do X”. Instead you declare a relationship: “the type IsString<T> is true when T extends string“. The compiler evaluates this lazily, on demand. This distinction — thinking in relationships rather than procedures — is the main mental shift required for type-level programming.

Conditional Types: More Than Just If-Else

The syntax T extends U ? X : Y looks deceptively simple, but it carries two powerful and distinct behaviours depending on whether T is a concrete type or a naked type parameter. Understanding this difference is essential.

Basic conditional types

// The fundamental form: T extends U ? TrueType : FalseType
type IsString<T> = T extends string ? true : false;

type A = IsString<string>;   // true
type B = IsString<number>;   // false
type C = IsString<"hello">; // true — literal types extend their base

// Extracting parts of types — the practical workhorse
type UnpackArray<T> = T extends Array<infer Item> ? Item : T;

type D = UnpackArray<string[]>;   // string
type E = UnpackArray<number[]>;   // number
type F = UnpackArray<boolean>;    /

Distributive conditional types: the non-obvious behaviour

This is where many developers get caught off guard. When a conditional type’s checked type is a naked type parameter (no wrapping), TypeScript automatically distributes the conditional over union members. It is as if TypeScript maps the conditional across each member of the union individually.

type ToArray<T> = T extends any ? T[] : never;

// With a union, T is distributed — each member gets its own array
type G = ToArray<string | number>;
// ? string[] | number[]  (NOT (string | number)[])

// To PREVENT distribution, wrap the type in a tuple
type ToArrayNoDistribute<T> = [T] extends [any] ? T[] : never;
type H = ToArrayNoDistribute<string | number>;
// ? (string | number)[]

// Practical use: Exclude from a union (this is how Exclude<T,U> works internally)
type MyExclude<T, U> = T extends U ? never : T;
type I = MyExclude<"a" | "b" | "c", "a">;
// → "b" | "c"  — TypeScript distributes, filters out "a", unions the rest

Distribution is how TypeScript’s built-in utility types like ExcludeExtract, and NonNullable are implemented. Recognising the pattern — filter a union by mapping never over the members you want to remove — is a fundamental building block you will use repeatedly.

The infer Keyword: Pattern Matching at the Type Level

If conditional types are the if statement of the type language, infer is its destructuring assignment. It declares a type variable inside an extends clause and binds whatever TypeScript infers at that position to a name you can use in the true branch. Critically, infer can only appear inside an extends clause of a conditional type — nowhere else.

// Extract a function's return type (what ReturnType<T> does under the hood)
type MyReturnType<T> =
  T extends (...args: any[]) => infer R ? R : never;

// Extract the first parameter's type
type FirstParam<T> =
  T extends (first: infer P, ...rest: any[]) => any ? P : never;

type Fn = (id: number, name: string) => boolean;
type J = MyReturnType<Fn>;  // boolean
type K = FirstParam<Fn>;    // number

// Multiple infer positions in one extends clause — both bind simultaneously
type UnpackPromise<T> =
  T extends Promise<infer Value> ? Value : T;

type L = UnpackPromise<Promise<string>>; // string
type M = UnpackPromise<number>;          // number (not a Promise, returns T)

// Inferring within tuples to split head from tail
type Head<T extends any[]> = T extends [infer H, ...any[]] ? H : never;
type Tail<T extends any[]> = T extends [any, ...infer T] ? T : never;

type N = Head<[string, number, boolean]>;  // string
type O = Tail<[string, number, boolean]>;  // [number, boolean]

The tuple split pattern — [infer H, ...infer T] — is the type-level analogue of a list’s head and tail from functional programming. Together with recursion (covered shortly), it is the foundation for almost every non-trivial type-level algorithm.

Template Literal Types: A String Parser Built Into the Type System

Added in TypeScript 4.1, template literal types let you construct and parse string types at compile time using the same backtick syntax as JavaScript template strings. What makes them powerful — not just decorative — is that they combine with infer to act as a genuine pattern-matching parser for string literal types.

Generating string unions from combinations

type Axis = "x" | "y" | "z";
type EventName = `on${Capitalize<Axis>}`;
// "onX" | "onY" | "onZ"  — TypeScript distributes over all union members

// Real-world: type-safe CSS property accessors
type Side = "top" | "right" | "bottom" | "left";
type SpacingProp = `${"margin" | "padding"}-${Side}`;
// "margin-top" | "margin-right" | "margin-bottom" | "margin-left"
// | "padding-top" | "padding-right" ... (8 members total)

// Remapping object keys — getters from property names
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};

type User = { name: string; age: number };
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }

Parsing strings with infer inside template literals

The real power emerges when you combine template literals with infer. TypeScript can match a string type against a pattern and extract the parts — essentially, it is a regex applied at the type level.

// Extract CSS unit from a value string
type ExtractUnit<T extends string> =
  T extends `${infer _}${"px" | "em" | "rem" | "%"}`
    ? T extends `${infer _}${infer Unit extends "px" | "em" | "rem" | "%"}`
      ? Unit
      : never
    : never;

// Route parameter extraction (the Express.js typing challenge)
type ExtractRouteParams<T extends string> =
  T extends `${infer _}:${infer Param}/${infer Rest}`
    ? { [K in Param]: string } & ExtractRouteParams<`/${Rest}`>
    : T extends `${infer _}:${infer Param}`
      ? { [K in Param]: string }
      : {};

type Params = ExtractRouteParams<"/users/:userId/posts/:postId">;
// { userId: string } & { postId: string }
// TypeScript merges these — hover in your IDE and you'll see { userId: string; postId: string }

// Split a dot-separated string into a tuple of its parts
type Split<S extends string, D extends string> =
  S extends `${infer Head}${D}${infer Tail}`
    ? [Head, ...Split<Tail, D>]
    : [S];

type Parts = Split<"a.b.c.d", ".">;
// ["a", "b", "c", "d"]

TypeScript 4.7+: infer with constraints

Since TypeScript 4.7, you can constrain an infer variable directly: infer R extends string. This narrows the inferred type immediately and avoids a follow-up extends check. It makes complex parsers significantly more readable. Instead of infer U followed by U extends string ? ..., write infer U extends string in one step.

Recursive Types: Loops in the Type Language

Since the type language has no for or while constructs, all iteration is expressed through recursion — a generic type that references itself. This is exactly how pure functional languages like Haskell work, and many of the same patterns apply.

TypeScript’s recursion depth limit

TypeScript imposes a recursion limit of roughly 45 type instantiations before throwing error 2589: “Type instantiation is excessively deep and possibly infinite.” This is not a bug — it is a deliberate guard against infinite loops, which are theoretically possible given the type system’s Turing completeness. The standard workaround is to use accumulator tuples to encode numeric values and limit depth explicitly.

Type-level algorithms: a deep equality check

// Flatten a nested array type — recursion over the element type
type Flatten<T> = T extends Array<infer Item>
  ? Flatten<Item>
  : T;

type Deep = Flatten<number[][][]>;  // number

// Deep readonly — recurse into every nested object
type DeepReadonly<T> = T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
  : T;

// Type-level arithmetic using tuple length as a counter
// This is the canonical pattern for working with numeric literals
type BuildTuple<N extends number, Acc extends unknown[] = []> =
  Acc["length"] extends N
    ? Acc
    : BuildTuple<N, [...Acc, unknown]>;

type Add<A extends number, B extends number> =
  [...BuildTuple<A>, ...BuildTuple<B>]["length"];

type Sum = Add<3, 4>;  // 7 — a numeric literal, not a type-level string

// Deep object path accessor — type-safe lodash.get signature
type PathValue<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? PathValue<T[Key], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;

type Config = { db: { host: string; port: number } };
type Host = PathValue<Config, "db.host">;  // string
type Port = PathValue<Config, "db.port">;  // number
type Bad  = PathValue<Config, "db.user">;  // never — "user" doesn't exist, caught at compile time

Putting It Together: A Real-World Type-Safe Event System

Theory becomes useful when it solves real problems. The following type-safe event emitter demonstrates how conditional types, infer, template literals, and mapped types compose into something genuinely practical — an event system where the compiler enforces that every emit call uses the correct payload type for that event name.

// Define your event map — string keys mapping to payload types
interface AppEvents {
  userCreated: { id: string; email: string };
  orderPlaced: { orderId: string; total: number };
  sessionExpired: { userId: string; reason: string };
}

// Derive a "listener" version of the map using mapped + conditional types
type EventListeners<Events> = {
  [K in keyof Events]?: ((payload: Events[K]) => void)[]
};

// Extract the payload type for a given event key
type EventPayload<Events, K extends keyof Events> = Events[K];

class TypedEmitter<Events extends Record<string, object>> {
  private listeners: EventListeners<Events> = {};

  // K is constrained to keyof Events — only valid event names compile
  on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): this {
    (this.listeners[event] ??= [] as any).push(handler);
    return this;
  }

  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach(h => h(payload));
  }
}

const emitter = new TypedEmitter<AppEvents>();

emitter.on("userCreated", ({ id, email }) => {
  // id and email are both inferred as string — no annotation needed
});

emitter.emit("orderPlaced", { orderId: "x42", total: 99.99 });

// ? These both produce compile errors — the type system enforces correctness
// emitter.emit("unknownEvent", {});          // Error: "unknownEvent" is not assignable to keyof AppEvents
// emitter.emit("orderPlaced", { id: "x" }); // Error: missing 'orderId' and 'total'

When Not to Use Type-Level Programming

TypeScript’s type system being Turing complete is intellectually fascinating — but it also means you can write type-level code that makes the compiler genuinely slow. Deeply recursive types, in particular, can cause tsc to become sluggish in large codebases. It is important to know where the boundary between useful and counterproductive lies.

PatternCompile costRecommendation
Simple conditional typesVery lowUse freely
Template literal unions (small)LowUse freely
Deep recursive types (depth < 10)Low–mediumFine in moderation
Template literal unions (combinatorial)Medium–highProfile first
Deep recursive types on large objectsHighUse with explicit depth limits
Deeply nested cross-referencing typesVery highReconsider the design

The combinatorial explosion trap

Template literal types that cross multiple large unions can generate enormous numbers of members. For example, a type combining 5 unions of 10 members each produces 100,000 literal types. TypeScript will either slow down dramatically or hit an internal limit. If you find yourself building CSS utility class names by crossing many unions, use template literals for documentation and inference, but constrain the union size with Pick or limit the cardinality deliberately.

Practical depth-limiting pattern

// A lookup table maps numeric types to their predecessors
// This is the canonical TypeScript pattern for bounded recursion
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// DeepPartial that stops at a configurable depth
type DeepPartial<T, Depth extends number = 5> =
  [Depth] extends [0]
    ? T                  // depth budget exhausted — return T as-is
    : T extends object
      ? { [K in keyof T]?: DeepPartial<T[K], Prev[Depth]> }
      : T;

// Now the recursion terminates at depth 5 even for hugely nested objects
type PartialConfig = DeepPartial<Config>;       // default depth 5
type ShallowConfig = DeepPartial<Config, 2>;  // only two levels deep

The satisfies operator (TypeScript 4.9+): a type-level assertion

TypeScript 4.9 introduced satisfies, which is worth understanding as a type-level tool. Unlike as (which overrides inference) or a plain annotation (which widens the type), satisfies validates that a value matches a type without losing the literal type information. It is a compile-time proof obligation with no runtime cost: const palette = { red: [255,0,0] } satisfies Record<string, number[]> — the variable is still typed as { red: number[] }, not widened to the broader record type, while also being verified against it.

Building a Mental Model: The Proof-System Lens

The “type system as proof system” framing is not just poetic — it is the correct theoretical model. In formal logic, a type corresponds to a proposition and a value of that type corresponds to a proof of that proposition. This is known as the Curry–Howard correspondence. TypeScript does not implement this in the strict dependent-type sense, but the analogy is practically useful.

1. A generic type is a function over types

    type F<T> = ... takes a type argument and returns a type. Just as a function in the value language transforms values, a generic type transforms types. Apply the same design instincts: small, composable, single-responsibility.

    2. A conditional type is a proof by case analysis

    T extends U ? X : Y says: “if we can prove T is a subtype of U, then the result type is X; otherwise Y.” The type checker evaluates this and picks the branch — it is literally a proof by cases.

    3. infer is pattern-matching on evidence

    When you write T extends Promise<infer R>, you are saying: “if there exists an R such that T is a Promise of R, bind R and use it.” This is pattern matching on the structure of the type — extracting its witnesses.

    4. never is the empty type (falsehood)

    never is the type with no values — you can never construct one. A function returning never never returns. In the proof system, never represents an impossible proposition: if a type reduces to never, the case can never occur. Returning never from a conditional type branch means “this case is impossible and filtered away.”

    5. Recursion is structural induction

    Recursive types work over the structure of their input, reducing toward a base case — exactly the definition of structural induction in mathematics. Flatten<T[]> = Flatten<T> recurses on a structurally smaller type each step until it reaches a non-array base case.

    What We Learned

    TypeScript operates as two languages simultaneously: the runtime JavaScript world and a compile-time type language with its own evaluation model. The type language is pure, functional, and — as demonstrated through formal proofs — Turing complete. Conditional types (T extends U ? X : Y) are the foundational branching construct, and their behaviour changes meaningfully depending on whether the checked type is a naked type parameter (which triggers distributivity over unions) or a wrapped one. The infer keyword, usable only inside an extends clause, enables structural pattern matching: you declare a type variable and bind whatever the compiler infers at that position, making it possible to extract return types, function parameters, tuple elements, and string components.

    Template literal types combine with infer to form a genuine string-parsing DSL at compile time — route parameter extraction, dot-path traversal, and CSS property generation are all expressible. Recursion replaces loops, with the Prev lookup-table pattern providing a bounded, depth-safe way to traverse arbitrary structures without hitting TypeScript’s ~45-level recursion limit. Applied together, these tools allow you to build type-safe APIs that encode domain rules directly into the type system, turning the compiler into a proof checker for your business logic before a single line of code runs.

    Eleftheria Drosopoulou

    Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
    Subscribe
    Notify of
    guest

    This site uses Akismet to reduce spam. Learn how your comment data is processed.

    0 Comments
    Oldest
    Newest Most Voted
    Inline Feedbacks
    View all comments
    Back to top button