Advanced TypeScript: Conditional Types, infer, Template Literal and Mapped Types
Key takeaways
Advanced TypeScript: conditional types, infer, template literals, mapped types, key remapping—type-safe events, reducers, and query objects.
Introduction
TypeScript’s advanced type system lets you write precise, type-safe code that scales with your domain. The features covered here — conditional types, infer, template literals, mapped types — aren’t independent tricks so much as a small type-level programming language layered on top of regular TypeScript: conditional types are its if, infer is how it introduces new bindings, and mapped types are how it loops over an object’s keys. Once that framing clicks, patterns like DeepPartial<T> below (a mapped type that recursively calls itself) stop looking like syntax tricks and start looking like ordinary recursive functions, just operating on types instead of values.
Conditional types
Basic syntax
A conditional type is evaluated entirely at compile time and produces no runtime code — IsString<T> doesn’t check anything when your program actually runs, it resolves to the literal type true or false the moment the compiler sees what T is. This is the core distinction between TypeScript’s type system and its runtime: T extends U asks “is every value assignable to U also assignable to T’s shape,” which is a structural question the compiler can answer statically, not something that needs an instanceof check or any other runtime work.
// T extends U ? X : Y
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
type C = IsString<"hello">; // true
Practical checks
// Is it an array?
type IsArray<T> = T extends any[] ? true : false;
type D = IsArray<string[]>; // true
type E = IsArray<number>; // false
// Is it a function?
type IsFunction<T> = T extends (...args: any[]) => any ? true : false;
type F = IsFunction<() => void>; // true
type G = IsFunction<string>; // false
T extends any[] reads naturally as “is T an array,” and T extends (...args: any[]) => any as “is T callable” — the anys here aren’t a type-safety compromise, they’re deliberately unconstrained placeholders meaning “any array of any element type” and “any function with any signature,” since the check only cares about the shape (array-ness, callable-ness), not the specifics of what’s inside.
Nested conditionals
type TypeName<T> =
T extends string ? "string" :
T extends number ? "number" :
T extends boolean ? "boolean" :
T extends undefined ? "undefined" :
T extends Function ? "function" :
"object";
type T1 = TypeName<string>; // "string"
type T2 = TypeName<42>; // "number"
type T3 = TypeName<() => void>; // "function"
Chained conditional types like this resolve top to bottom, exactly like an if/else-if chain — the compiler checks each branch in order and stops at the first one T satisfies, falling through to "object" if nothing else matched. Order matters here for the same reason it matters in the exception-handling except clauses of other languages: a broader check placed earlier would shadow a narrower one placed after it (though this particular chain happens to check mutually-exclusive primitive types, so reordering wouldn’t actually change behavior here).
The infer keyword
Concept
infer is the part of a conditional type that lets it extract a piece of T’s structure into a new type variable, rather than just testing T against a fixed shape — without it, conditional types could only answer yes/no questions, but with it, they can pull out and return a sub-part of the type being checked. T extends (infer U)[] ? U : never reads as “if T has the shape of an array of something, capture that ‘something’ as U and return it; otherwise, return never” — the type checker essentially pattern-matches T against the shape (infer U)[], and if it matches, binds U to whatever filled that slot.
infer introduces a type variable inside a conditional type.
// Element type of an array
type ElementType<T> = T extends (infer U)[] ? U : never;
type H = ElementType<string[]>; // string
type I = ElementType<number[]>; // number
type J = ElementType<string>; // never
Inferring return types
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: "U001", name: "Alice" };
}
type User = MyReturnType<typeof getUser>;
// { id: string; name: string; }
function getNumber(): number {
return 42;
}
type Num = MyReturnType<typeof getNumber>; // number
This is, in fact, exactly how TypeScript’s own built-in ReturnType<T> utility is implemented — MyReturnType here isn’t a simplified toy version of a fundamentally different mechanism, it’s the same pattern the standard library uses. typeof getUser is worth calling out too: in a type position, typeof pulls the type of a value (here, a function’s full signature) rather than the runtime string typeof normally returns — a common point of confusion for anyone who’s only seen typeof used as a JavaScript runtime operator before.
Inferring parameter types
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
function createUser(name: string, age: number, email: string) {
return { name, age, email };
}
type Params = MyParameters<typeof createUser>;
// [string, number, string]
// First parameter only
type FirstParam<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type First = FirstParam<typeof createUser>; // string
The rest-parameter pattern ((...args: infer P) => any) infers the entire parameter list at once as a tuple type — P becomes [string, number, string], preserving each parameter’s position and type rather than collapsing them into a single array type. FirstParam demonstrates that infer can appear anywhere inside the shape being matched, not just at the top level — (first: infer F, ...rest: any[]) => any specifically captures only the first parameter’s type while treating the rest as an unconstrained any[] it doesn’t care about.
Unwrapping Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;
type K = Awaited<Promise<string>>; // string
type L = Awaited<Promise<number>>; // number
type M = Awaited<string>; // string
Note the fallback branch here (: T, not : never) — when T isn’t a Promise, the type resolves to T itself unchanged rather than an error type, which is what makes this useful as a general “unwrap if wrapped, pass through otherwise” utility instead of one that only works when you already know the input is a Promise. This is also, not coincidentally, the same shape as TypeScript’s real built-in Awaited<T> type, which async/await uses internally to figure out what an awaited expression resolves to.
Template literal types
Basics
Template literal types apply the same interpolation syntax as JavaScript’s template strings, but at the type level — `on${Capitalize<EventName>}` doesn’t build one string, it distributes over every member of the EventName union and produces a new union of every resulting string literal type. This is genuinely useful for keeping two related sets of string literals in sync automatically: if EventName gains a new event (say "submit"), EventHandler picks up "onSubmit" without anyone updating it by hand, and any code relying on the old, narrower set of handler names gets a compile error pointing at exactly what needs updating.
type EventName = "click" | "focus" | "blur";
type EventHandler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"
interface Events {
onClick: () => void;
onFocus: () => void;
onBlur: () => void;
}
String built-ins
type Greeting = "hello world";
type Upper = Uppercase<Greeting>; // "HELLO WORLD"
type Lower = Lowercase<Greeting>; // "hello world"
type Cap = Capitalize<Greeting>; // "Hello world"
type Uncap = Uncapitalize<Greeting>; // "hello world"
These four are TypeScript’s only intrinsic string-manipulation types — implemented directly in the compiler rather than expressible in userland TypeScript, unlike everything else in this guide. They exist specifically to compose inside template literal types, which is exactly what powered `on${Capitalize<EventName>}` in the previous example: without Capitalize, there’d be no way to transform "click" into "Click" at the type level to build the conventional onClick-style handler name.
API shapes
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type Resource = "users" | "posts" | "comments";
type ApiEndpoint = `/${Resource}`;
// "/users" | "/posts" | "/comments"
type ApiRoute = `${HttpMethod} ${ApiEndpoint}`;
// "GET /users" | "POST /users" | ...
type IdEndpoint = `${ApiEndpoint}/${string}`;
// "/users/:id" | "/posts/:id" | ...
`${ApiEndpoint}/${string}` illustrates a subtlety worth flagging: interpolating the generic string type (rather than a specific union of literals) produces a pattern type that matches any string with that prefix and suffix — "/users/abc", "/users/42", and "/users/" all satisfy it — rather than an enumerable, finite union like the earlier ApiEndpoint and ApiRoute examples. This is genuinely useful for validating route parameter shapes at compile time (catching /users passed where /users/:id was expected), but it’s worth knowing the type system doesn’t further validate that :id portion is, say, numeric — that still needs a runtime check.
Mapped types
Basic mapping
A mapped type iterates over every key of T ([K in keyof T]) and produces a new property for each one, which is what makes it TypeScript’s equivalent of a for...in loop at the type level. The modifiers matter as much as the mapping itself: readonly in MyReadonly and ? in MyPartial add those modifiers to every property, while -? in MyRequired specifically removes the optional modifier — the - prefix (and the corresponding + you can write explicitly, though it’s the default) is how mapped types express “strip this modifier” rather than only ever adding one.
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};
interface User {
name: string;
age?: number;
}
type ReadonlyUser = MyReadonly<User>;
type PartialUser = MyPartial<User>;
type RequiredUser = MyRequired<User>;
Key remapping
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface User {
name: string;
age: number;
}
type UserGetters = Getters<User>;
// {
// getName: () => string;
// getAge: () => number;
// }
The as clause here is key remapping — it lets a mapped type change the name of each resulting property, not just its value type, which the basic mapped types above couldn’t do (they always kept the original key names). string & K is a small but necessary trick: K alone has type keyof T, which TypeScript treats as string | number | symbol in general (object keys aren’t guaranteed to be strings), and Capitalize only accepts an actual string type — intersecting with string filters K down to just its string-compatible part so Capitalize<string & K> type-checks.
Filtering keys by value type
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K];
};
interface User {
id: string;
name: string;
age: number;
isActive: boolean;
}
type StringProps = PickByType<User, string>;
// { id: string; name: string; }
type NumberProps = PickByType<User, number>;
// { age: number; }
The T[K] extends U ? K : never idiom is what makes filtering-by-value-type possible: mapping the key to itself (K) keeps it, mapping it to never drops it, because TypeScript’s mapped types automatically omit any property whose computed key type is never — it’s not a special case handled elsewhere, it falls naturally out of how key remapping already works. This PickByType pattern is genuinely useful for things like “give me only the string fields of this interface for a search-by-text feature” without manually listing them and having that list drift out of sync as the interface evolves.
Practical examples
Example 1: Type-safe event bus
This is the example that makes the whole chapter’s abstract machinery pay off concretely: on and emit are both generic over K extends keyof T, and TypeScript ties the type of the event name to the type of its associated data through that shared generic — calling emitter.emit("user:login", { userId, timestamp }) gets full autocomplete on the payload shape, and passing a post:create payload to a user:login event is a compile error, not a runtime surprise discovered later. This is meaningfully safer than the untyped EventEmitter most JS event-bus libraries provide, where event names are just strings and payload shapes are undocumented and unchecked until something crashes at runtime.
type EventMap = {
"user:login": { userId: string; timestamp: Date };
"user:logout": { userId: string };
"post:create": { postId: string; title: string };
"post:delete": { postId: string };
};
class EventEmitter<T extends Record<string, any>> {
private listeners: {
[K in keyof T]?: Array<(data: T[K]) => void>;
} = {};
on<K extends keyof T>(event: K, listener: (data: T[K]) => void) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof T>(event: K, data: T[K]) {
const listeners = this.listeners[event];
if (listeners) {
listeners.forEach(listener => listener(data));
}
}
}
const emitter = new EventEmitter<EventMap>();
emitter.on("user:login", (data) => {
console.log(`User logged in: ${data.userId}`);
});
emitter.emit("user:login", {
userId: "U001",
timestamp: new Date()
});
Example 2: Typed actions for state
This models the same problem a Redux-style reducer solves, but derives the action type strings from the state shape itself instead of hand-writing them (type Action<T> = { type: SET_${Uppercase<string & T>}, ... }), so adding a new field to State automatically produces a correctly-typed, correctly-named action for it. Actions then distributes over every key of State via the mapped type, and [keyof State] at the end converts the resulting object-of-types into a plain union — that’s a genuinely useful pattern (sometimes called the mapped-type distribution trick) any time you need “the union of all possible shapes this generic could take,” rather than a single specific instantiation.
type State = {
user: { name: string; email: string } | null;
posts: Array<{ id: string; title: string }>;
loading: boolean;
};
type Action<T extends keyof State> = {
type: `SET_${Uppercase<string & T>}`;
payload: State[T];
};
type Actions = {
[K in keyof State]: Action<K>;
}[keyof State];
function reducer(state: State, action: Actions): State {
switch (action.type) {
case "SET_USER":
return { ...state, user: action.payload };
case "SET_POSTS":
return { ...state, posts: action.payload };
case "SET_LOADING":
return { ...state, loading: action.payload };
default:
return state;
}
}
const initialState: State = {
user: null,
posts: [],
loading: false
};
const newState = reducer(initialState, {
type: "SET_USER",
payload: { name: "Alice", email: "[email protected]" }
});
Example 3: Type-safe query object
This is a small taste of the type patterns behind real query builders like Prisma or Drizzle: a nested mapped type where the outer layer ([K in keyof T]) walks the entity’s own fields, and the inner layer ([O in QueryOperator]) attaches every comparison operator to each one — so age: { gte: 20, lt: 30 } type-checks against age: number, but name: { gte: "..." } would be a compile error, since string comparison operators like gte don’t make semantic sense there in this simplified version (a real implementation would typically further restrict which operators apply to which field types).
type QueryOperator = "eq" | "ne" | "gt" | "lt" | "gte" | "lte";
type Query<T> = {
[K in keyof T]?: {
[O in QueryOperator]?: T[K];
};
};
interface User {
id: string;
name: string;
age: number;
email: string;
}
function find<T>(query: Query<T>): T[] {
return [];
}
const users = find<User>({
age: { gte: 20, lt: 30 },
name: { eq: "Alice" }
});
Advanced utility patterns
DeepPartial
The reason a plain Partial<T> (from TypeScript’s standard utility types) isn’t enough for nested objects is that it only makes the top-level properties optional — profile would still need its full shape supplied if present, since Partial doesn’t recurse into it. DeepPartial<T> fixes that by checking, per property, whether T[K] extends object and recursively applying itself if so — a self-referential type alias, exactly like a recursive function, which is why it can handle arbitrarily nested shapes like profile.address.city without the definition itself needing to know how deep the nesting goes. This pattern is genuinely common in real code for PATCH-style API updates, where a caller only supplies the fields they’re actually changing, at any depth.
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
interface User {
id: string;
profile: {
name: string;
address: {
city: string;
zipCode: string;
};
};
}
const update: DeepPartial<User> = {
profile: {
address: {
city: "Seoul"
}
}
};
DeepReadonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
const user: DeepReadonly<User> = {
id: "U001",
profile: {
name: "Alice",
address: {
city: "Seoul",
zipCode: "12345"
}
}
};
DeepReadonly mirrors DeepPartial’s recursion but for immutability instead of optionality — worth pairing with the earlier note that plain Readonly<T> also only protects the top level; without recursing, user.profile.name = "..." would still compile even under a shallow Readonly<User>, since profile itself isn’t marked readonly, only the top-level profile binding is. It’s a compile-time-only guarantee, too, same caveat as the readonly array/object patterns elsewhere in TypeScript: nothing stops a runtime Object.assign or a cast around the type from mutating the underlying object — the protection exists purely in what the type checker will and won’t let normally-typed code do.
Next in the series
- TypeScript project: REST API
- TypeScript utility types — most of the built-ins there are one-line mapped or conditional types of the kind built in this post
- TypeScript generics
Related Articles
- Python decorators | @decorator explained
- TypeScript getting started | install, config, syntax
- Advanced TypeScript types | unions, intersections, literals
- TypeScript interfaces | complete guide
- TypeScript Decorators
- TypeScript REST API Project | Express· Layered Architecture
- TypeScript Utility Types: Partial, Pick, Omit, Record, ReturnType and Where Each Fits