Introduction: The Magic Hidden in Types
\n\nImagine you are writing not just code, but a set of instructions for the compiler itself, which, based on your rules, generates safe, self-documenting constructs. This is metaprogramming in TypeScript — the ability to write code that works at the type level, analyzing and transforming other types during compilation.
\n\nMany developers stop at basic generics and interfaces, unaware of the powerful arsenal hidden behind keywords like infer, extends, and conditional constructs. In this article, we will explore advanced metaprogramming techniques in TypeScript that will transform you from a simple user of the language into its true architect.
1. Conditional Types: If at the Type Level
\n\nConditional types are the foundation of any type-level programming. They work like a ternary operator, but at the type system level:
\n\ntype IsString<T> = T extends string ? "yes" : "no";\n\ntype Test1 = IsString<"hello">; // "yes"\ntype Test2 = IsString<42>; // "no"\n\n\nBut the real magic begins when we combine them with generics. For example, let's create a type that extracts only the string keys of an object:
\n\ntype StringKeys<T> = {\n [K in keyof T]: T[K] extends string ? K : never\n}[keyof T];\n\ninterface User {\n name: string;\n age: number;\n email: string;\n}\n\ntype UserStringKeys = StringKeys<User>; // "name" | "email"\n\n\nThis example shows how advanced TypeScript types allow you to filter and transform data structures at compile time. You are literally programming logic that executes before the code reaches runtime.
\n\n2. Infer: Extracting Types from Other Types
\n\nThe infer keyword is perhaps the most powerful tool in the TypeScript metaprogramming arsenal. It allows you to "look inside" a type and extract its constituent parts. Let's look at a classic example — getting the return type of a function:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;\n\ntype Fn = (x: number) => string;\ntype FnReturn = ReturnType<Fn>; // string\n\n\nBut infer can do much more. Let's extract the type of the first argument of a function:
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;\n\ntype Handler = (id: number, name: string) => void;\ntype HandlerFirstArg = FirstArg<Handler>; // number\n\n\nAnd what about recursive parsing of promises? Let's create a type that "unwraps" all nested Promise types:
\n\ntype UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T;\n\ntype DeepPromise = Promise<Promise<Promise<number>>>;\ntype Unwrapped = UnwrapPromise<DeepPromise>; // number\n\n\nNotice the recursion — this is one of the most elegant advanced TypeScript techniques, allowing you to handle infinitely nested structures.
\n\n3. Mapped Types: Transforming Objects
\n\nMapped types allow you to create new types based on existing ones by iterating over their keys. This is an indispensable tool for creating generic utilities:
\n\ntype Readonly<T> = {\n readonly [K in keyof T]: T[K];\n};\n\ntype Partial<T> = {\n [K in keyof T]?: T[K];\n};\n\n\nBut we can go further. Let's create a type that makes all properties optional, but only if they are strings:
\n\ntype PartialStringProps<T> = {\n [K in keyof T]: T[K] extends string ? T[K] | undefined : T[K];\n};\n\ninterface Config {\n nam