TypeScript Advanced Generics
In this guide, we'll dive deep into TypeScript's advanced generic patterns. While you might already be familiar with basic generics, these advanced techniques will help you create more flexible, reusable, and type-safe code.
Introduction to Advanced Generics
Generics are one of TypeScript's most powerful features, allowing you to create reusable components that work with a variety of types rather than a single one. Advanced generic patterns extend this capability, enabling you to build sophisticated type-safe abstractions.
Let's start with a quick refresher on basic generics before diving into the advanced concepts.
// Basic generic function
function identity<T>(arg: T): T {
return arg;
}
// Usage
const num = identity<number>(42); // num is number
const str = identity<string>("hello"); // str is string
Generic Constraints
Generic constraints allow you to limit the types that can be used with your generic functions or classes.
Using the extends
Keyword
// Constraining a type to have a specific property
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(arg: T): T {
console.log(arg.length);
return arg;
}
// Works with strings (they have length)
logLength("hello"); // Output: 5
// Works with arrays (they have length)
logLength([1, 2, 3]); // Output: 3
// Works with objects that have a length property
logLength({ length: 10, value: "test" }); // Output: 10
// Error: number doesn't have a length property
// logLength(42); // Type error!
Using Multiple Type Parameters with Constraints
You can use multiple type parameters, each with their own constraints:
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const result = merge({ name: "John" }, { age: 30 });
console.log(result); // Output: { name: "John", age: 30 }
// This would cause a type error because 42 is not an object
// merge({ name: "John" }, 42);
Generic Conditional Types
Conditional types let you create types that depend on conditions, similar to if statements but for types.
Basic Conditional Types
type IsString<T> = T extends string ? true : false;
// Usage
type Result1 = IsString<"hello">; // true
type Result2 = IsString<42>; // false
The infer
Keyword
The infer
keyword allows you to extract types from other types:
// Extract the return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function greet(): string {
return "Hello";
}
type GreetReturn = ReturnType<typeof greet>; // string
// Extract array element type
type ArrayElement<T> = T extends (infer U)[] ? U : never;
type Numbers = ArrayElement<number[]>; // number
type Strings = ArrayElement<string[]>; // string
Generic Mapped Types
Mapped types allow you to create new types based on old ones by transforming properties.
Basic Mapped Types
type ReadOnly<T> = {
readonly [K in keyof T]: T[K];
};
interface User {
name: string;
age: number;
}
const user: ReadOnly<User> = {
name: "John",
age: 30
};
// This would cause a type error:
// user.name = "Jane"; // Cannot assign to 'name' because it is a read-only property
Advanced Mapped Types with Modifiers
// Make all properties optional
type Partial<T> = {
[K in keyof T]?: T[K];
};
// Make all properties required
type Required<T> = {
[K in keyof T]-?: T[K];
};
// Make all properties nullable
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
const partialUser: Partial<User> = { name: "John" }; // age is optional
const requiredUser: Required<User> = { name: "John", age: 30 }; // Both properties required
const nullableUser: Nullable<User> = { name: null, age: 30 }; // Name can be null
Template Literal Types with Generics
TypeScript 4.1 introduced template literal types, which work wonderfully with generics:
type EventName<T extends string> = `${T}Changed`;
type UserEvents = EventName<"name" | "age" | "email">;
// Expands to: "nameChanged" | "ageChanged" | "emailChanged"
// Creating a function that listens to events
function listen<T extends string>(eventName: EventName<T>, callback: () => void) {
// Implementation
}
listen("nameChanged", () => console.log("Name changed"));
// Error: "userUpdated" is not assignable to parameter of type "nameChanged" | "ageChanged" | "emailChanged"
// listen("userUpdated", () => {});
Generic Type Inference
TypeScript can often infer generic types automatically, making your code cleaner:
function map<T, U>(array: T[], callback: (item: T) => U): U[] {
return array.map(callback);
}
// TypeScript infers T as number and U as string
const lengths = map([1, 2, 3], (n) => n.toString());
// lengths is of type string[]
Higher-Order Type Functions
You can create types that operate on other types, similar to higher-order functions:
// Higher order type that applies a transformation to array elements
type MapArray<T, U> = T extends (infer Item)[]
? U extends (x: Item) => infer Result
? Result[]
: never
: never;
type NumbersToStrings = MapArray<number[], (n: number) => string>; // string[]
Real-World Examples
Let's look at some practical applications of advanced generics:
Example 1: Type-Safe API Client
// Define API endpoints and their request/response types
interface ApiEndpoints {
"/users": {
get: {
response: User[];
};
post: {
request: { name: string; email: string };
response: User;
};
};
"/users/:id": {
get: {
response: User;
};
put: {
request: { name?: string; email?: string };
response: User;
};
delete: {
response: { success: boolean };
};
};
}
// Type-safe API client
class ApiClient {
async get<Path extends keyof ApiEndpoints>(
path: Path
): Promise<
Path extends keyof ApiEndpoints
? "get" extends keyof ApiEndpoints[Path]
? ApiEndpoints[Path]["get"]["response"]
: never
: never
> {
const response = await fetch(path.toString());
return response.json();
}
async post<Path extends keyof ApiEndpoints>(
path: Path,
data: Path extends keyof ApiEndpoints
? "post" extends keyof ApiEndpoints[Path]
? ApiEndpoints[Path]["post"]["request"]
: never
: never
): Promise<
Path extends keyof ApiEndpoints
? "post" extends keyof ApiEndpoints[Path]
? ApiEndpoints[Path]["post"]["response"]
: never
: never
> {
const response = await fetch(path.toString(), {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
return response.json();
}
}
// Usage
const api = new ApiClient();
// Type-safe API calls
async function fetchData() {
// TypeScript knows this returns User[]
const users = await api.get("/users");
// TypeScript knows this needs a specific request shape and returns a User
const newUser = await api.post("/users", {
name: "John",
email: "[email protected]"
});
// This would be a type error because the shape is wrong
// await api.post("/users", { name: "John" });
}
Example 2: Type-Safe Event Emitter
type EventMap = {
click: { x: number; y: number };
hover: { element: string };
submit: { data: Record<string, unknown> };
};
class TypedEventEmitter<Events extends Record<string, any>> {
private listeners: {
[E in keyof Events]?: ((data: Events[E]) => void)[];
} = {};
on<E extends keyof Events>(event: E, listener: (data: Events[E]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]?.push(listener);
}
emit<E extends keyof Events>(event: E, data: Events[E]): void {
this.listeners[event]?.forEach(listener => listener(data));
}
}
// Usage
const emitter = new TypedEventEmitter<EventMap>();
// Type-safe event handlers
emitter.on("click", ({ x, y }) => {
console.log(`Clicked at ${x}, ${y}`);
});
emitter.on("submit", ({ data }) => {
console.log("Form submitted:", data);
});
// Type-safe event emissions
emitter.emit("click", { x: 100, y: 200 });
emitter.emit("hover", { element: "button" });
// This would cause a type error:
// emitter.emit("click", { element: "button" });
Recursive Generic Types
You can create recursive types that reference themselves:
// A tree node type that can contain children of the same type
type TreeNode<T> = {
value: T;
children?: TreeNode<T>[];
};
// Usage
const fileSystem: TreeNode<string> = {
value: "root",
children: [
{ value: "home", children: [{ value: "user", children: [{ value: "documents" }] }] },
{ value: "etc", children: [{ value: "config" }] }
]
};
// Function to search a tree
function findInTree<T>(
tree: TreeNode<T>,
predicate: (value: T) => boolean
): T | undefined {
if (predicate(tree.value)) {
return tree.value;
}
for (const child of tree.children || []) {
const found = findInTree(child, predicate);
if (found) return found;
}
return undefined;
}
// Find a node with value "user"
const result = findInTree(fileSystem, value => value === "user");
console.log(result); // Output: "user"
Generic Type Challenges
Here are a few generic type challenges to test your understanding:
Challenge 1: Create a DeepReadonly
Type
Create a type that makes all properties in an object (and all nested objects) readonly:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: DeepReadonly<T[K]>
: T[K];
};
interface Config {
server: {
host: string;
port: number;
};
database: {
url: string;
credentials: {
username: string;
password: string;
};
};
}
const config: DeepReadonly<Config> = {
server: {
host: "localhost",
port: 8080
},
database: {
url: "postgres://localhost:5432",
credentials: {
username: "admin",
password: "secret"
}
}
};
// This would cause type errors:
// config.server.host = "newhost";
// config.database.credentials.password = "newpassword";
Challenge 2: Create a Type-Safe Pipe Function
Create a function that composes multiple functions together in a type-safe way:
// Define our pipe function type
type Pipe<A, B, C> =
((a: A) => B) extends infer FirstFn
? ((b: B) => C) extends infer SecondFn
? [FirstFn, SecondFn]
: never
: never;
// Implementation with correct typings
function pipe<A, B, C>(
value: A,
fns: Pipe<A, B, C>
): C {
const [first, second] = fns;
return (second as (b: B) => C)(
(first as (a: A) => B)(value)
);
}
// Usage example
const double = (n: number) => n * 2;
const toString = (n: number) => n.toString();
const addExclamation = (s: string) => s + "!";
// Type-safe pipe - TypeScript knows the result is string
const result1 = pipe(5, [double, toString]); // "10"
const result2 = pipe(5, [double, addExclamation]); // Type error!
Summary
Advanced generics in TypeScript provide powerful tools for creating flexible, reusable, and type-safe code:
- Generic Constraints - Limit which types can be used with generics
- Conditional Types - Create types that depend on type conditions
- The
infer
Keyword - Extract types from other types - Mapped Types - Transform existing types into new ones
- Template Literal Types - Combine string literals with type operations
- Higher-Order Type Functions - Create types that operate on other types
- Recursive Types - Define types that reference themselves
Mastering these concepts will allow you to build sophisticated type-safe abstractions and catch more errors at compile time rather than runtime.
Additional Resources
To further enhance your understanding of advanced generics:
- Explore TypeScript's utility types like
Partial<T>
,Required<T>
,Pick<T, K>
, andOmit<T, K>
- Practice creating your own utility types
- Study the TypeScript source code to see how the built-in utility types are implemented
- Try type challenges from the TypeScript Type Challenges repository
Exercise Ideas
- Create a type-safe
useState
-like hook that remembers the type of the initial state - Build a generic memoization function that preserves the function's parameter and return types
- Implement a type-safe version of
Promise.all
that correctly infers the tuple type - Create a deep partial type that makes all properties optional at any depth
- Build a type-safe router with path parameters and query string parsing
By practicing these concepts, you'll be able to leverage TypeScript's advanced generic features to create more robust applications with fewer bugs.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)