A deep dive into TypeScript's most powerful type features — conditional types, mapped types, infer keyword, and template literal types. Real patterns you'll actually use.
Most TypeScript developers plateau at the same place. They know interfaces, generics, union types. They can type a React component. They can make the red squiggles go away. But they treat the type system as a necessary evil — something that exists to prevent bugs, not something that actively helps them design better software.
I stayed at that plateau for about two years. What pulled me out was realizing that TypeScript's type system is a programming language in itself. It has conditionals, loops, pattern matching, recursion, and string manipulation. Once you internalize that, everything changes. You stop fighting the compiler and start collaborating with it.
This post covers the type-level features I use constantly in production code. Not academic exercises — real patterns that have saved me from real bugs.
Before diving into syntax, I want to reframe how you think about types.
In value-level programming, you write functions that transform data at runtime:
function extractIds(users: User[]): number[] {
return users.map(u => u.id);
}In type-level programming, you write types that transform other types at compile time:
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];The mental model is the same — input, transformation, output. The difference is that type-level code runs during compilation, not at runtime. It produces zero JavaScript. Its only purpose is to make impossible states unrepresentable.
If you've ever written a function where three of the four parameters only make sense in certain combinations, or where the return type depends on what you pass in, you've already needed type-level programming. You just might not have known TypeScript could express it.
Conditional types are the if/else of the type system. The syntax looks like a ternary:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // trueThe extends keyword here doesn't mean inheritance. It means "is assignable to." Think of it as "does T fit into the shape of string?"
Let's rebuild some of TypeScript's built-in utility types to understand how they work.
NonNullable — removes null and undefined from a union:
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Test = MyNonNullable<string | null | undefined>;
// Result: stringThe never type is the "empty set" — removing something from a union. When a branch of a conditional type resolves to never, that member of the union simply disappears.
Extract — keeps only union members that match a constraint:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | booleanExclude — the opposite, removes matching members:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | booleanHere's where things get interesting, and where most people get confused. When you pass a union type to a conditional type, TypeScript distributes the condition over each member of the union individually.
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// You might expect: (string | number)[]
// Actual result: string[] | number[]TypeScript doesn't evaluate (string | number) extends unknown. Instead, it evaluates string extends unknown and number extends unknown separately, then unions the results.
This is why Extract and Exclude work the way they do. The distribution happens automatically when T is a "naked" type parameter (not wrapped in anything).
If you want to prevent distribution, wrap both sides in a tuple:
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]I've been bitten by this more times than I care to admit. If your conditional type is giving unexpected results with union inputs, distribution is almost always the reason.
Here's a pattern I use when dealing with API responses that can either succeed or fail:
type ApiResponse<T> =
| { status: "success"; data: T; error: never }
| { status: "error"; data: never; error: string };
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
type UserResponse = ApiResponse<{ id: number; name: string }>;
type UserData = ExtractData<UserResponse>;
// Result: { id: number; name: string }
type UserError = ExtractError<UserResponse>;
// Result: stringThis is cleaner than writing manual type guards for every response shape.
infer Keyword#infer is pattern matching for types. It lets you declare a type variable inside a conditional type that TypeScript will figure out for you.
Think of it as: "I know this type has a certain shape. Pull out the part I care about."
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // booleanTypeScript sees the pattern (...args) => something, matches your function against it, and assigns the return type to R.
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]This one I use all the time when working with async functions:
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnpackPromise<Promise<string>>; // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>; // string (passthrough)For deeply nested promises:
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: numbertype ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberinfer in One Type#You can use infer multiple times to extract different parts:
type FirstAndLast<T extends unknown[]> =
T extends [infer First, ...unknown[], infer Last]
? { first: First; last: Last }
: never;
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Result: { first: 1; last: 4 }Here's a pattern I use in event-driven systems:
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"order:created": { orderId: string; total: number };
"order:shipped": { orderId: string; trackingId: string };
};
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
// Extract all user-related events
type UserEvents = Extract<EventName, `user:${string}`>;
// Result: "user:login" | "user:logout"
// Get the payload for user events only
type UserEventPayloads = EventPayload<UserEvents>;
// Result: { userId: string; timestamp: number } | { userId: string }Mapped types let you create new object types by transforming each property of an existing type. They're the map() of the type system.
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};keyof T gives you a union of all property keys. [K in ...] iterates over each one. T[K] is the type of that property.
Required — make all properties mandatory:
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};The -? syntax removes the optional modifier. Similarly, -readonly removes readonly.
Pick — select specific properties:
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
interface User {
id: number;
name: string;
email: string;
password: string;
}
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }Omit — remove specific properties:
type MyOmit<T, K extends keyof T> = {
[P in Exclude<keyof T, K>]: T[P];
};
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }Record — create an object type with specific keys and value types:
type MyRecord<K extends string | number | symbol, V> = {
[P in K]: V;
};
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }as#TypeScript 4.1 introduced key remapping, which lets you transform property names:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
email: string;
}
type PersonGetters = Getters<Person>;
// {
// getName: () => string;
// getAge: () => number;
// getEmail: () => string;
// }You can also filter properties by remapping to never:
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }This is incredibly powerful. You're simultaneously iterating over properties, filtering them, and transforming their names — all at the type level.
I've used this pattern in several form libraries:
interface FormFields {
username: string;
email: string;
age: number;
bio: string;
}
type ValidationErrors<T> = {
[K in keyof T]?: string[];
};
type TouchedFields<T> = {
[K in keyof T]?: boolean;
};
type DirtyFields<T> = {
[K in keyof T]?: boolean;
};
type FormState<T> = {
values: T;
errors: ValidationErrors<T>;
touched: TouchedFields<T>;
dirty: DirtyFields<T>;
isValid: boolean;
isSubmitting: boolean;
};
// Now your form state is fully typed based on the fields
type MyFormState = FormState<FormFields>;Add a field to FormFields, and every related type updates automatically. Remove a field, and the compiler catches every reference. This is the kind of thing that prevents entire categories of bugs.
Template literal types let you manipulate strings at the type level. They use the same backtick syntax as JavaScript template literals, but for types.
type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, world"; // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world"; // Error!TypeScript also provides built-in string manipulation types:
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"This is where template literal types really shine. Here's a pattern I use for type-safe event systems:
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"Nine events from two type parameters. And they're all type-safe.
Template literal types can enforce CSS patterns at the type level:
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
function setWidth(value: CSSValue): void {
// implementation
}
setWidth("100px"); // OK
setWidth("2.5rem"); // OK
setWidth("50%"); // OK
// setWidth("100"); // Error: not a valid CSS value
// setWidth("big"); // ErrorYou can use template literal types with infer to parse strings:
type ParseRoute<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | ParseRoute<`/${Rest}`>
: T extends `${infer _Start}:${infer Param}`
? Param
: never;
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Result: "userId" | "postId"This is the foundation of how type-safe routing libraries like tRPC work. The string /users/:userId/posts/:postId is parsed at the type level to extract parameter names.
This is where things get truly powerful:
type EventHandlers<T extends Record<string, unknown>> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (
newValue: T[K],
oldValue: T[K]
) => void;
};
interface ThemeConfig {
color: string;
fontSize: number;
darkMode: boolean;
}
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
// onColorChange: (newValue: string, oldValue: string) => void;
// onFontSizeChange: (newValue: number, oldValue: number) => void;
// onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }From a simple config interface, you get fully typed event handlers with correct parameter types. This is the kind of type-level code that actually saves development time.
Here's a more advanced pattern — generating all possible dot-notation paths through a nested object:
type DotPaths<T, Prefix extends string = ""> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string]
: never;
interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
server: {
port: number;
};
}
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"Now you can write a getConfig function that autocompletes every possible path and returns the correct type for each:
type GetValueByPath<T, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? GetValueByPath<T[Key], Rest>
: never
: P extends keyof T
? T[P]
: never;
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
// implementation
const keys = path.split(".");
let result: unknown = config;
for (const key of keys) {
result = (result as Record<string, unknown>)[key];
}
return result as GetValueByPath<Config, P>;
}
const host = getConfig("database.host"); // type: string
const port = getConfig("database.port"); // type: number
const username = getConfig("database.credentials.username"); // type: stringFull autocomplete, full type safety, zero runtime overhead from the types.
TypeScript supports recursive type definitions, which lets you handle arbitrarily nested structures.
The built-in Partial<T> only makes top-level properties optional. For nested objects, you need recursion:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object
? T[K] extends Array<infer U>
? Array<DeepPartial<U>>
: DeepPartial<T[K]>
: T[K];
};
interface Settings {
theme: {
colors: {
primary: string;
secondary: string;
};
fonts: {
heading: string;
body: string;
};
};
notifications: {
email: boolean;
push: boolean;
};
}
type PartialSettings = DeepPartial<Settings>;
// Every nested property is now optionalNote the array check: without it, arrays would be treated as objects and their numeric indices would become optional, which isn't what you want.
Same pattern, different modifier:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: T[K] extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: DeepReadonly<T[K]>
: T[K];
};The Function check is important — without it, function properties would get readonly applied to their internal structure, which doesn't make sense.
Here's a classic recursive type — representing any valid JSON value:
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
function parseJson(input: string): JsonValue {
return JSON.parse(input);
}
function isJsonObject(value: JsonValue): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}This is much better than using any for JSON parsing. It tells the consumer "you'll get back something, but you need to narrow it before using it." Which is exactly the truth.
A practical example — typing a navigation menu structure:
interface MenuItem {
label: string;
href: string;
icon?: string;
children?: MenuItem[];
}
type Menu = MenuItem[];
const navigation: Menu = [
{
label: "Products",
href: "/products",
children: [
{
label: "Software",
href: "/products/software",
children: [
{ label: "IDE", href: "/products/software/ide" },
{ label: "CLI Tools", href: "/products/software/cli" },
],
},
{ label: "Hardware", href: "/products/hardware" },
],
},
{ label: "About", href: "/about" },
];The recursive children property gives you infinite nesting with full type safety.
Let me share some patterns I've actually used in production systems.
The builder pattern becomes significantly more useful when the type system tracks what's been set:
type RequiredKeys = "host" | "port" | "database";
type BuilderState = {
[K in RequiredKeys]?: true;
};
class DatabaseConfigBuilder<State extends BuilderState = {}> {
private config: Partial<{
host: string;
port: number;
database: string;
ssl: boolean;
poolSize: number;
}> = {};
setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
this.config.host = host;
return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
}
setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
this.config.port = port;
return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
}
setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
this.config.database = db;
return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
}
setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
this.config.ssl = ssl;
return this as unknown as DatabaseConfigBuilder<State>;
}
setPoolSize(size: number): DatabaseConfigBuilder<State> {
this.config.poolSize = size;
return this as unknown as DatabaseConfigBuilder<State>;
}
build(
this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
): Required<Pick<typeof this.config, "host" | "port" | "database">> &
Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
return this.config as ReturnType<typeof this.build>;
}
}
// This compiles:
const config = new DatabaseConfigBuilder()
.setHost("localhost")
.setPort(5432)
.setDatabase("myapp")
.setSsl(true)
.build();
// This fails at compile time — missing .setDatabase():
// const bad = new DatabaseConfigBuilder()
// .setHost("localhost")
// .setPort(5432)
// .build(); // Error: 'database' is missingThe build() method only becomes callable when all required fields have been set. This is caught at compile time, not runtime.
Here's an event emitter where the payload types are enforced:
type EventMap = Record<string, unknown>;
class TypedEventEmitter<Events extends EventMap> {
private listeners: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
on<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners[event]?.forEach((listener) => listener(payload));
}
off<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
const handlers = this.listeners[event];
if (handlers) {
this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
}
}
}
// Usage
interface AppEvents {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"notification:new": { message: string; level: "info" | "warning" | "error" };
}
const emitter = new TypedEventEmitter<AppEvents>();
// Fully typed — IDE autocompletes event names and payload shapes
emitter.on("user:login", (payload) => {
console.log(payload.userId); // string
console.log(payload.timestamp); // number
});
// Type error: payload doesn't match
// emitter.emit("user:login", { userId: "123" });
// Error: missing 'timestamp'
// Type error: event doesn't exist
// emitter.on("user:signup", () => {});
// Error: "user:signup" is not in AppEventsI've used this pattern in three different production projects. It catches entire categories of event-related bugs at compile time.
Discriminated unions are probably the single most useful pattern in TypeScript. Combined with exhaustive checking, they guarantee you handle every case:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
// This line ensures exhaustive checking
const _exhaustive: never = shape;
return _exhaustive;
}
}If someone adds a new shape variant (say "pentagon"), this function will fail to compile because never can't be assigned a value. The compiler forces you to handle every case.
I take this further with a helper function:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
}
}Discriminated unions also model state machines beautifully:
type ConnectionState =
| { status: "disconnected" }
| { status: "connecting"; attempt: number }
| { status: "connected"; socket: WebSocket; connectedAt: Date }
| { status: "error"; error: Error; lastAttempt: Date };
type ConnectionAction =
| { type: "CONNECT" }
| { type: "CONNECTED"; socket: WebSocket }
| { type: "DISCONNECT" }
| { type: "ERROR"; error: Error }
| { type: "RETRY" };
function connectionReducer(
state: ConnectionState,
action: ConnectionAction
): ConnectionState {
switch (state.status) {
case "disconnected":
if (action.type === "CONNECT") {
return { status: "connecting", attempt: 1 };
}
return state;
case "connecting":
if (action.type === "CONNECTED") {
return {
status: "connected",
socket: action.socket,
connectedAt: new Date(),
};
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "connected":
if (action.type === "DISCONNECT") {
state.socket.close();
return { status: "disconnected" };
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "error":
if (action.type === "RETRY") {
return { status: "connecting", attempt: 1 };
}
if (action.type === "DISCONNECT") {
return { status: "disconnected" };
}
return state;
default:
assertNever(state, "Unknown connection state");
}
}Each state only has the properties that make sense for that state. You can't access socket when disconnected. You can't access error when connected. The type system enforces the state machine's constraints.
One more pattern I find essential — using branded types to prevent mixing up values that happen to share the same underlying type:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getUser(id: UserId): Promise<User> {
// ...
}
function getOrder(id: OrderId): Promise<Order> {
// ...
}
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
getUser(userId); // OK
// getUser(orderId); // Error! OrderId is not assignable to UserIdBoth UserId and OrderId are strings at runtime. But at compile time, they're distinct types. You literally cannot pass an order ID where a user ID is expected. This has caught real bugs in every project where I've used it.
Advanced types are powerful, but they come with traps. Here's what I've learned the hard way.
TypeScript has a recursion depth limit for types (currently around 50 levels, though it varies). If you go too deep, you'll get the dreaded "Type instantiation is excessively deep and possibly infinite" error.
// This will hit the recursion limit for deeply nested objects
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// Fix: Add a depth counter
type DeepPartialSafe<T, Depth extends number[] = []> =
Depth["length"] extends 10
? T
: {
[K in keyof T]?: T[K] extends object
? DeepPartialSafe<T[K], [...Depth, 0]>
: T[K];
};The depth counter trick uses a tuple that grows with each recursive step. When it hits your limit, the recursion stops.
Complex types can significantly slow down your editor and build times. I've seen projects where a single overly clever type added 3 seconds to every keystroke's feedback loop.
Warning signs:
tsc --noEmit takes noticeably longer after adding a type// This is too clever — it generates a combinatorial explosion
type AllCombinations<T extends string[]> = T extends [
infer First extends string,
...infer Rest extends string[]
]
? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
: "";
// Don't do this with more than 5-6 elementsThis might be the most important section. Advanced types should be used when they prevent bugs or improve developer experience. They should not be used to show off.
Don't use them when:
Record<string, unknown> would sufficeDo use them when:
// Over-engineered — don't do this for a simple config
type Config = DeepReadonly<
DeepRequired<
Merge<DefaultConfig, Partial<UserConfig>>
>
>;
// Just do this
interface Config {
readonly host: string;
readonly port: number;
readonly debug: boolean;
}My rule of thumb: if explaining the type takes longer than explaining the bug it prevents, simplify it.
When a complex type isn't working, I use this helper to "see" what TypeScript has resolved:
// Expands a type for inspection in IDE hover tooltips
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
// Use it to debug
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Now hover over Debug in your IDE to see the resolved typeThe & {} trick forces TypeScript to eagerly evaluate the type instead of showing you the type alias. It's saved me hours of confusion.
Another technique — isolate and test incrementally:
// Instead of debugging this all at once:
type Final = StepThree<StepTwo<StepOne<Input>>>;
// Break it apart:
type AfterStep1 = StepOne<Input>; // hover to check
type AfterStep2 = StepTwo<AfterStep1>; // hover to check
type AfterStep3 = StepThree<AfterStep2>; // hover to checkT extends U ? X : Y) are if/else for types. Watch out for distributive behavior with unions.infer is pattern matching — use it to extract types from structures like function signatures, promises, and arrays.{ [K in keyof T]: ... }) iterate over properties. Combine with as for key remapping and filtering.The TypeScript type system is Turing complete, which means you can do almost anything with it. The art is knowing when you should.