TypeScript’s type system can perform complex computations and logic, but these operations have zero runtime cost because they happen entirely at compile time.
Let’s see how this works. Imagine you have a set of string literals representing HTTP methods:
type HttpMethods = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
Now, suppose you want to create a type that represents all possible HTTP methods except "GET". You could do this with a mapped type and a conditional type:
type ExcludeGet<T extends string> = {
[K in T]: K extends "GET" ? never : K;
}[T];
type MethodsWithoutGet = ExcludeGet<HttpMethods>;
// "POST" | "PUT" | "DELETE" | "PATCH"
Here’s what’s happening:
ExcludeGet<T extends string>: We define a generic typeExcludeGetthat accepts any string literal typeT.{ [K in T]: K extends "GET" ? never : K; }: This is a mapped type. It iterates over each memberKof the input typeT.K extends "GET" ? never : K: For each memberK, it checks ifKis "GET". If it is, it maps it tonever(which effectively removes it from a union). If it’s not "GET", it keepsKas is.- So, for
HttpMethods, this intermediate mapped type would look something like:{ "GET": never; "POST": "POST"; "PUT": "PUT"; "DELETE": "DELETE"; "PATCH": "PATCH"; }
[T]: We then access the values of this mapped object using the original unionT. When you access the "properties" of this object using the unionT, TypeScript collects all the resulting values into a new union type."GET" | "POST" | "PUT" | "DELETE" | "PATCH"indexed into the above object results innever | "POST" | "PUT" | "DELETE" | "PATCH", which simplifies to"POST" | "PUT" | "DELETE" | "PATCH".
This isn’t just about manipulating unions. You can build incredibly complex logic. Consider a type that flattens a nested array type:
type Flatten<T> = T extends (infer U)[] ? U : T;
type NestedArray = (string | number)[][];
type FlatArray = Flatten<NestedArray>;
// (string | number)[]
This Flatten type is actually a simplified version of TypeScript’s built-in Flatten utility type. It uses infer U within the conditional type to extract the element type of an array. If T is an array (T extends (infer U)[]), it returns U (the element type). Otherwise, it returns T itself.
The real power comes when you combine these. Let’s say you want to create a type that recursively flattens an array of arrays of arrays:
type DeepFlatten<T> = T extends (infer U)[] ? DeepFlatten<U> : T;
type DeeplyNested = (string | number)[][][];
type DeeplyFlat = DeepFlatten<DeeplyNested>;
// string | number
Here, DeepFlatten calls itself recursively. If T is an array, it tries to flatten its elements (U) until T is no longer an array, at which point it returns T.
You can even perform arithmetic on numbers at the type level, though this is significantly more verbose and less common than string/union manipulation. It typically involves representing numbers as tuples and using conditional types to simulate addition or subtraction.
For example, representing numbers as tuples:
type Zero = [];
type One = [any];
type Two = [any, any];
type Three = [any, any, any];
type Add<A extends any[], B extends any[]> = [...A, ...B];
type Sum = Add<Two, Three>;
// [any, any, any, any, any] - a tuple of length 5
The "result" of Add<Two, Three> is a tuple of length 5. To get the numeric value, you’d typically use a helper type that infers the length of a tuple.
The elegance of type-level programming is that the TypeScript compiler is the runtime. When you save your file, the compiler analyzes your types, performs these computations, and reports any errors. These operations have no impact on the JavaScript that eventually runs in the browser or on the server. The entire computation is erased during the compilation process.
One common pattern you’ll see is using keyof and as for sophisticated property manipulation. If you have a type representing a configuration object and you want to create a new type where all string values are replaced by a specific string literal, you can do this:
interface AppConfig {
appName: string;
version: string;
port: number;
debugMode: boolean;
}
type MaskStringValues<T> = {
[K in keyof T]: T[K] extends string ? "MASKED" : T[K];
};
type MaskedConfig = MaskStringValues<AppConfig>;
/*
{
appName: "MASKED";
version: "MASKED";
port: number;
debugMode: boolean;
}
*/
This uses keyof T to get all property names and then, for each property K, it checks if its type T[K] is a string. If it is, it maps it to "MASKED"; otherwise, it keeps the original type.
The most surprising aspect is how deeply you can nest these conditional types and mapped types to build complex state machines or parsers entirely within the type system, and the compiler handles it efficiently. It feels like writing a functional programming language that happens to operate on types.
The next logical step is exploring how to use these type-level computations to infer and enforce complex relationships between different parts of your data structures, often seen in libraries managing state or API responses.