TypeScript Advanced Types: Conditional, Mapped, and Template...
TypeScript’s type system is Turing-complete — you can express complex type relationships that transform, filter, and combine types at compile time. Advanced types enable patterns that would otherwise require runtime checks or code generation.
This guide covers conditional types, mapped types, template literal types, recursive type aliases, and how they work together to create expressive, type-safe APIs.
Conditional Types
Conditional types select a type based on a condition using the syntax T extends U ? X : Y. They enable type-level programming — types that compute other types.
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
The infer Keyword
infer extracts types from within conditional branches. It is how built-in utility types like ReturnType and Parameters work:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Fn = (x: number) => string;
type R = ReturnType<Fn>; // string
infer can extract deeply nested types. For example, extracting the element type from a Promise array:
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type Unwrapped = Unwrap<Promise<string>>; // string
Distributive Conditional Types
Conditional types distribute over union types. When T is a union, the condition is applied to each member:
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// string[] | number[] (not (string | number)[])
To prevent distribution, wrap the condition in square brackets: [T] extends [any].
Mapped Types
Mapped types transform the properties of an existing type. They iterate over keys and create new properties based on the originals.
type Readonly<T> = {
readonly [K in keyof T]: T[K];
---;
type Optional<T> = {
[K in keyof T]?: T[K];
---;
type Nullable<T> = {
[K in keyof T]: T[K] | null;
---;Key Remapping (TS 4.1+)
You can rename keys using as:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
---;
interface Person {
name: string;
age: number;
---
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number; }
Filtering Properties
Combine key remapping with as never to remove properties:
type OnlyStringValues<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
---;
type Example = OnlyStringValues<{ name: string; age: number }>;
// { name: string }
Template Literal Types
Template literal types (TS 4.1+) manipulate string types at the type level using the same syntax as JavaScript template literals:
type EventName = `on${Capitalize<string>}`;
// Matches anything starting with "on" followed by a capitalized string
String Manipulation Built-ins
TypeScript provides four built-in string manipulation types: Uppercase<S>, Lowercase<S>, Capitalize<S>, and Uncapitalize<S>. These are compiler-intrinsic and work on string literal types.
Practical Patterns
Template literal types enable type-safe event handlers:
type EventHandler<T extends string> =
T extends `on${infer Event}` ? (event: Event) => void : never;
function addListener<T extends string>(
el: HTMLElement,
event: T,
handler: EventHandler<T>
): void {}
addListener(button, "onClick", (e) => {}); // Valid
They also power CSS property autocompletion and database query builders by constraining string patterns at the type level.
Recursive Type Aliases
TypeScript supports recursive type aliases for nested structures:
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? DeepReadonly<T[K]>
: T[K];
---;Recursive Conditional Types
Combine recursion with conditional types to traverse complex types:
type DeepPick<T, Path extends string> =
Path extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? DeepPick<T[Key], Rest>
: never
: Path extends keyof T
? T[Path]
: never;
type Data = { user: { profile: { name: string } } };
type Name = DeepPick<Data, "user.profile.name">; // string
Practical Combinations
The real power comes from combining these techniques. For example, a type-safe event emitter:
type EventMap = {
click: { x: number; y: number };
focus: { element: HTMLElement };
keydown: { key: string };
---;
type Emitter = {
on<K extends keyof EventMap>(
event: K,
handler: (data: EventMap[K]) => void
): void;
emit<K extends keyof EventMap>(
event: K,
data: EventMap[K]
): void;
---;This pattern — mapping event names to their payload types — provides autocompletion and type checking for event emitters, state managers, and API clients.
Summary
Advanced TypeScript types enable powerful type-level programming. Conditional types compute types based on conditions. Mapped types transform object types. Template literal types manipulate string types. Recursive types handle nested structures. Combined, they produce APIs that are expressive, type-safe, and self-documenting.
FAQ
What is the difference between conditional types and mapped types? Conditional types select between types based on a condition (T extends U ? X : Y). Mapped types transform the properties of an object type ({ [K in keyof T]: T[K] }). They can be combined — mapped types can use conditional types for value transformations.
When should I use infer? Use infer inside conditional types to extract and capture a type from a larger structure. Common use cases include extracting return types, promise values, function parameters, and array element types.
How do template literal types improve DX? They enable autocompletion for string patterns like event names, CSS properties, and API paths. Combined with infer, they can parse and validate string formats at compile time.
Are recursive types expensive? TypeScript has a depth limit (typically 50 levels) for recursive type evaluation. Deeply recursive types can slow down the compiler. Use them judiciously for nested structures of known depth.
Conditional Types in Depth
Conditional types select a type based on a condition, enabling type-level programming:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
Combined with infer, conditional types extract types from other types:
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;This enables powerful utilities like Parameters<T>, ConstructorParameters<T>, and InstanceType<T> that introspect function and constructor signatures. The TypeScript standard library uses these extensively — ReturnType<typeof myFunction> extracts the return type without manual annotation.
Mapped Types with Key Remapping
TypeScript 4.1 introduced key remapping in mapped types using as:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
---;
type Person = { name: string; age: number };
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }
Template Literal Types
Template literal types create string literal types through pattern matching:
type EventName<T extends string> = `on${Capitalize<T>}`;
type ClickEvent = EventName<"click">; // "onClick"
type ChangeEvent = EventName<"change">; // "onChange"
This is invaluable for type-safe event handlers, CSS property APIs, and any domain where string patterns encode meaning. Combined with conditional types, template literals enable compile-time validation of string formats.
Type Branding for Nominal Typing
TypeScript uses structural typing — types are compatible if they have the same shape, not the same name. For situations requiring nominal typing (distinguishing IDs of different entities), use type branding:
type Brand<T, B> = T & { __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
function getUser(id: UserId): User { /* ... */ }
function getOrder(id: OrderId): Order { /* ... */ }
const uid = "abc" as UserId;
const oid = "def" as OrderId;
getUser(uid); // OK
getUser(oid); // Type error — prevents passing OrderId to getUser
Brands are erased at runtime — they exist only at compile time. This pattern catches misuses like passing a user ID to a function expecting an order ID without any runtime overhead.
The satisfies Operator
TypeScript 4.9 introduced the satisfies operator, which validates a type without changing the inferred type:
type Color = "red" | "green" | "blue";
const palette = {
primary: "red",
secondary: "blue",
tertiary: "green"
--- satisfies Record<string, Color>;
// palette.primary is typed as "red" (literal), not Color
// But assigning an invalid color would cause a compile error
This is useful for configuration objects, maps, and any scenario where you want validation without widening the type.
Practical Type Patterns for Large Codebases
When building large TypeScript applications, several patterns keep type complexity manageable:
Discriminated unions for API responses — Model every API endpoint as a union of success, error, and loading states:
type AsyncState<T, E = Error> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: E };This pattern eliminates impossible states — you cannot accidentally access data when status is "loading". The compiler enforces exhaustive handling of all states, preventing common bugs.
Zod for runtime validation — TypeScript types are compile-time only. Use Zod to validate external data at runtime and infer the type:
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(["admin", "user"]),
---);
type User = z.infer<typeof UserSchema>;
// Validate API response at runtime
const user = UserSchema.parse(apiResponse);
// user is fully typed as User
This dual approach provides runtime safety for data entering your system (API responses, file parsing, user input) and compile-time safety everywhere else.
Avoiding Type Complexity Traps
Complex types (nested conditionals, deep mapped types, recursive type aliases) can make code harder to understand. Apply these guidelines:
- Keep conditional types shallow — avoid nesting beyond 2-3 levels
- Name intermediate types instead of inlining complex expressions
- Prefer interfaces over type aliases for object shapes (interfaces have better error messages and are faster to check)
- Use
typefor unions, intersections, and mapped types - Extract reusable type utilities into a shared types module
Related: TypeScript Generics Guide | Utility Types