Why Advanced Types Are Needed
Basic types and generics cover most TypeScript code. However, when building libraries, expressing complex data transformation logic through types, or deriving new types from existing ones, advanced type techniques become necessary.
This article covers conditional types, mapped types, the infer keyword, template literal types, and recursive types.
Conditional Types
Conditional types follow the T extends U ? X : Y pattern, like a ternary operator at the type level.
// Basic conditional type
type IsString<T> = T extends string ? "yes" : "no";
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
type C = IsString<"hello">; // "yes" — literal types also extend string
// Practical use: branching API response types
type ApiEndpoint = "/users" | "/posts" | "/comments";
type ApiResponse<T extends ApiEndpoint> =
T extends "/users" ? { id: number; name: string; email: string }[] :
T extends "/posts" ? { id: number; title: string; body: string }[] :
T extends "/comments" ? { id: number; postId: number; text: string }[] :
never;
// Response type is automatically determined by endpoint
type UsersResponse = ApiResponse<"/users">;
// { id: number; name: string; email: string }[]
type PostsResponse = ApiResponse<"/posts">;
// { id: number; title: string; body: string }[]
// Distributive conditional types
// When a union type is passed, the condition is distributed over each member
type ToArray<T> = T extends unknown ? T[] : never;
type StrOrNumArray = ToArray<string | number>;
// string[] | number[] — not (string | number)[]!
// To prevent distribution, wrap in brackets
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Combined = ToArrayNonDist<string | number>;
// (string | number)[]
The infer Keyword
infer is used within conditional types to extract types. It means “whatever the type at this position is, capture it.”
// Extract function return type (same principle as ReturnType)
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
function fetchUser() {
return { id: 1, name: "Alice", email: "alice@example.com" };
}
type UserData = MyReturnType<typeof fetchUser>;
// { id: number; name: string; email: string }
// Extract function parameter types
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
function createUser(name: string, age: number, email: string): void {
console.log(name, age, email);
}
type CreateUserParams = MyParameters<typeof createUser>;
// [name: string, age: number, email: string]
// Extract inner type from a Promise
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Resolved = UnwrapPromise<Promise<string>>; // string
type NotPromise = UnwrapPromise<number>; // number
// Recursively unwrap nested Promises
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;
type Deep = DeepUnwrap<Promise<Promise<Promise<number>>>>;
// number
// Extract array element type
type ElementType<T> = T extends (infer E)[] ? E : T;
type NumElement = ElementType<number[]>; // number
type StrElement = ElementType<string[]>; // string
type Mixed = ElementType<(string | number)[]>; // string | number
// Extract object value types
type ValueOf<T> = T[keyof T];
const STATUS = {
ACTIVE: "active",
INACTIVE: "inactive",
PENDING: "pending",
} as const;
type StatusValue = ValueOf<typeof STATUS>;
// "active" | "inactive" | "pending"
Mapped Types
Mapped types iterate over each property of an existing type to create a new type. They follow the { [K in keyof T]: ... } pattern.
// Make all properties optional (Partial implementation)
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Make all properties required (Required implementation)
type MyRequired<T> = {
[K in keyof T]-?: T[K]; // -? removes the optional modifier
};
// Make all properties readonly (Readonly implementation)
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
// Practical: make all properties nullable
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
interface User {
id: number;
name: string;
email: string;
}
type NullableUser = Nullable<User>;
// { id: number | null; name: string | null; email: string | null }
// Key remapping (as clause)
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string; getEmail: () => string }
// Filter properties by type
type StringKeys<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
interface Product {
id: number;
title: string;
description: string;
price: number;
}
type StringProduct = StringKeys<Product>;
// { title: string; description: string } — number properties excluded
// Auto-generate event handlers
type EventHandlers<T> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (value: T[K]) => void;
};
interface FormFields {
username: string;
age: number;
active: boolean;
}
type FormHandlers = EventHandlers<FormFields>;
// {
// onUsernameChange: (value: string) => void;
// onAgeChange: (value: number) => void;
// onActiveChange: (value: boolean) => void;
// }
Template Literal Types
Since TypeScript 4.1, you can combine string literal types to create new string types.
// Basic template literal type
type Greeting = `Hello, ${string}!`;
const g1: Greeting = "Hello, World!"; // OK
// const g2: Greeting = "Hi, World!"; // Error!
// Combined with unions: generates all combinations
type Color = "red" | "green" | "blue";
type Size = "sm" | "md" | "lg";
type ColorSize = `${Color}-${Size}`;
// "red-sm" | "red-md" | "red-lg" | "green-sm" | "green-md" | "green-lg" | "blue-sm" | "blue-md" | "blue-lg"
// CSS unit type
type CSSUnit = "px" | "rem" | "em" | "%";
type CSSValue = `${number}${CSSUnit}`;
const width: CSSValue = "100px"; // OK
const height: CSSValue = "2.5rem"; // OK
// const invalid: CSSValue = "100"; // Error! No unit
// Built-in string utility types
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"
// Practical: extract router parameters
type ExtractParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"
// Convert parameters to object type
type RouteParams<T extends string> = {
[K in ExtractParams<T>]: string;
};
type UserPostParams = RouteParams<"/users/:userId/posts/:postId">;
// { userId: string; postId: string }
Practical Application: Type-Safe Event System
Let’s combine the techniques covered so far to build a type-safe event system.
// Define event map
interface EventMap {
userLogin: { userId: number; timestamp: Date };
userLogout: { userId: number };
pageView: { path: string; referrer: string | null };
purchase: { productId: number; quantity: number; total: number };
}
// Type-safe event emitter
class TypedEventEmitter<T extends Record<string, unknown>> {
private handlers = new Map<keyof T, Set<(data: unknown) => void>>();
on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler as (data: unknown) => void);
}
off<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
this.handlers.get(event)?.delete(handler as (data: unknown) => void);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
this.handlers.get(event)?.forEach((handler) => handler(data));
}
}
// Usage: event names and data types are linked
const emitter = new TypedEventEmitter<EventMap>();
emitter.on("userLogin", (data) => {
// data type is inferred as { userId: number; timestamp: Date }
console.log(`User ${data.userId} logged in: ${data.timestamp}`);
});
emitter.on("purchase", (data) => {
// data type is inferred as { productId: number; quantity: number; total: number }
console.log(`Purchase: product ${data.productId}, ${data.quantity} items, total $${data.total}`);
});
// Type checking applied
emitter.emit("userLogin", { userId: 1, timestamp: new Date() }); // OK
// emitter.emit("userLogin", { userId: "abc" }); // Error! userId must be number
// emitter.emit("unknown", {}); // Error! "unknown" is not in EventMap
Summary
- Conditional types (
T extends U ? X : Y) are branching logic at the type level. When applied to union types, they distribute over each member. - infer extracts types within conditional types. Use it to extract function return types, Promise inner types, array element types, and more.
- Mapped types iterate over properties of an existing type and transform them. Use
asclauses for key remapping and conditional types for filtering. - Template literal types express string patterns as types. They provide powerful type checking for routers, CSS values, event names, and more.
- Advanced types are powerful, but keeping them at a level your team can understand is important. Excessive type gymnastics can make maintenance difficult.