A Practical Guide to Conditional and Recursive Types in TypeScript
TypeScript has grown far beyond its initial purpose of offering static typing for JavaScript. Over the years, its type system has evolved into a powerful computational layer capable of performing operations during compilation. Developers now use TypeScript not only to validate shapes and contracts but also to compute, transform, and analyse types in ways that resemble functional programming.
This article revisits the topic of conditional and recursive logic in the type system. These ideas have been around for a while, but the language changes frequently, and earlier explanations sometimes lack clarity or skip important edge cases. Here, the goal is to give you a more human explanation, grounded in practical examples, without artificial patterns or overly academic language.
The article is written from the perspective that you already use TypeScript daily, but perhaps have not yet explored the deeper parts of its type system. Everything is explained gradually, starting with conditional types, then recursion, and finally combining both.
Understanding TypeScript’s Type Computation Model
TypeScript does not execute JavaScript at compile time. Instead, it uses structural rules, pattern matching, distributive conditional types, and recursive expansion to determine the shape of a type. When we talk about “type gymnastics”, we are referring to the process of using these capabilities to compute types in a structured way.
Three key concepts enable most advanced patterns:
- Conditional types — a mechanism similar to an
ifstatement for types. - Recursive types — a pattern where a type refers to itself until a stopping condition is met.
- Tuple operations — extending and deconstructing arrays to accumulate changes or iterate.
Individually, these concepts are powerful, but their real value emerges when they are combined.
Conditional Types: The Type-Level “If”
Conditional types allow decisions to be made within the type system. They follow a simple structure:
type Condition<C, T, F> = C extends true ? T : F;This corresponds to JavaScript’s ternary operator. But TypeScript conditional types work with any type relationship, not only booleans.
Basic Conditional Check
Here is a minimal example:
type IsString<T> = T extends string ? "string" : "not-string";
type A = IsString<string>; // "string"
type B = IsString<number>; // "not-string"The mechanic is simple: if T extends string, the type resolves to the first branch. Otherwise, the second.
Narrowing With Conditional Types
Conditional types are also useful for enforcing more precise constraints.
type ExtractString<T> = T extends string ? T : never;
type Result = ExtractString<string | number>; // stringThe conditional distributes across unions, so it checks each member separately. This is the mechanism behind TypeScript’s built‑in utility types like Extract, Exclude, and NonNullable.
Recursive Types: Looping Without Loops
TypeScript does not allow loops in type definitions, but recursion makes similar behaviour possible. Instead of executing statements, TypeScript evaluates type definitions by repeatedly applying the rules until a final state is reached.
Building a Tuple of Length N
A common example is constructing an array purely at the type level.
type BuildArray<
N extends number,
Acc extends unknown[] = []
> = Acc["length"] extends N
? Acc
: BuildArray<N, [...Acc, 1]>;This type continues to grow the tuple until its length matches N.
type Three = BuildArray<3>; // [1, 1, 1]
type Five = BuildArray<5>; // [1, 1, 1, 1, 1]TypeScript eventually stops expanding once Acc["length"] reaches the target.
Recursively Transforming Array Types
Recursion is also useful for mapping and transforming elements:
type MapToStrings<Arr extends any[]> =
Arr extends [infer F, ...infer R]
? [F extends number ? `${F}` : F, ...MapToStrings<R>]
: [];This example converts numeric elements to their string representations.
type Out = MapToStrings<[1, "a", 3]>; // ["1", "a", "3"]Combining Conditional Logic and Recursion
The techniques become most expressive when used together. Imagine filtering an array at the type level:
Filtering Elements Based on a Type
type Filter<
Arr extends any[],
Match
> = Arr extends [infer F, ...infer R]
? F extends Match
? [F, ...Filter<R, Match>]
: Filter<R, Match>
: [];Usage:
type OnlyNumbers = Filter<[1, "a", 2, "b"], number>; // [1, 2]This pattern mirrors the logic of a filter callback in JavaScript.
Replacing Items Conditionally
Another common transformation is replacing some elements depending on their type.
type Replace<
Arr extends any[],
Condition,
With
> = Arr extends [infer F, ...infer R]
? F extends Condition
? [With, ...Replace<R, Condition, With>]
: [F, ...Replace<R, Condition, With>]
: [];Example:
type Out = Replace<[1, "a", 2], number, "num">;
// ["num", "a", "num"]Counting Items Matching a Condition
Numbers cannot be incremented directly in the type system, but tuple lengths serve as counters.
type Count<
Arr extends any[],
Match,
Acc extends unknown[] = []
> = Arr extends [infer F, ...infer R]
? F extends Match
? Count<R, Match, [...Acc, 1]>
: Count<R, Match, Acc>
: Acc["length"];type Counted = Count<[1, "a", 2, "b", 3], number>; // 3This technique is useful for validation logic or checking invariants at compile time.
Grouping Values
Type-level partitioning allows grouping into “matches” and “non-matches”.
type Partition<
Arr extends any[],
Match,
T extends any[] = [],
F extends any[] = []
> = Arr extends [infer X, ...infer R]
? X extends Match
? Partition<R, Match, [...T, X], F>
: Partition<R, Match, T, [...F, X]>
: [T, F];Example:
type Groups = Partition<[1, "a", 2, "b"], number>;
// [[1, 2], ["a", "b"]]Patterns for Real Codebases
Type-level computation is tempting to overuse, but it is most effective when applied to solve realistic problems:
- Enforcing “fixed length” arrays.
- Validating configuration objects.
- Constructing route types in frontend frameworks.
- Creating type-safe query builders.
- Building reusable utility types for libraries.
As a rule of thumb, recursive conditional types should solve structure‑level problems, not data‑processing ones. If the result will never influence runtime behaviour, it is usually safe.
Conclusion
Conditional types and recursive types are not separate tools—they complement one another. Together they allow TypeScript to express constraints and transformations that resemble compile-time computation. With these techniques, you can build filters, mappers, counters, partitions, and more, all within the type system.
Used thoughtfully, type gymnastics strengthens the robustness of your code without introducing unnecessary complexity. The examples here are a foundation; once familiar with the patterns, you can adapt them to more complex real-world abstractions.