TypeScript Generics
Create reusable, type-safe components that work with any data type while maintaining type integrity.
TypeScript Generics
Master TypeScript generics with free flashcards and spaced repetition practice. This lesson covers generic functions, generic classes, generic constraints, and utility typesβessential concepts for writing reusable, type-safe TypeScript code that scales across your applications.
Welcome to TypeScript Generics π»
Imagine you're building a storage box, but you don't know yet whether it will hold books, shoes, or electronics. Instead of building three different boxes, wouldn't it be better to build one flexible box that adapts to whatever you put inside? That's exactly what generics do in TypeScriptβthey let you write code that works with multiple types while maintaining complete type safety.
Generics are one of TypeScript's most powerful features, enabling you to create reusable components that work across different data types without sacrificing the benefits of static typing. Whether you're building utility functions, data structures, or complex APIs, generics will become an indispensable tool in your TypeScript toolkit.
Core Concepts π―
What Are Generics?
Generics are a way to create reusable code components that work with multiple types rather than a single one. They act as type variables that allow you to capture the type information passed to a function, class, or interface, and use that information throughout your code.
Think of generics as type parametersβjust like function parameters let you pass values, generic parameters let you pass types.
Basic syntax:
function identity<T>(arg: T): T {
return arg;
}
Here, <T> is the generic type parameter. T is a convention (short for "Type"), but you can use any valid identifier.
π‘ Tip: Use descriptive names for generic parameters when working with multiple types: <TKey, TValue> is clearer than <T, U> for a map structure.
Why Use Generics?
Without generics, you'd have three options, all with drawbacks:
| Approach | Problem | Example |
|---|---|---|
| Using `any` | Loses type safety completely | function identity(arg: any): any |
| Using specific types | Requires duplicate code for each type | function identityString(arg: string): string, function identityNumber(arg: number): number |
| Using union types | Limited flexibility, complex return types | function identity(arg: string | number): string | number |
Generics solve all these problems by maintaining type relationships while remaining flexible.
Generic Functions π§
Generic functions are the foundation of generics in TypeScript. They allow you to write functions that preserve type information through the call.
Example: A generic array wrapper
function wrapInArray<T>(value: T): T[] {
return [value];
}
const numberArray = wrapInArray(42); // Type: number[]
const stringArray = wrapInArray("hello"); // Type: string[]
const boolArray = wrapInArray(true); // Type: boolean[]
TypeScript infers the generic type from the argument you pass, but you can also specify it explicitly:
const result = wrapInArray<string>("explicit"); // Explicitly set T = string
Working with multiple generic parameters:
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const nameAndAge = pair("Alice", 30); // Type: [string, number]
const coords = pair(10.5, 20.3); // Type: [number, number]
Generic Constraints π
Sometimes you need to restrict what types can be used with a generic. Generic constraints let you specify that a type parameter must have certain properties or extend a particular type.
Using the extends keyword:
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(arg: T): void {
console.log(arg.length); // Safe! We know T has a length property
}
logLength("hello"); // β
Works: strings have length
logLength([1, 2, 3]); // β
Works: arrays have length
logLength({ length: 10 }); // β
Works: object has length
logLength(42); // β Error: numbers don't have length
Constraining to specific types:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: "Bob", age: 25 };
const name = getProperty(person, "name"); // Type: string
const age = getProperty(person, "age"); // Type: number
const invalid = getProperty(person, "xyz"); // β Error: "xyz" not in person
The constraint K extends keyof T ensures that key must be a valid property name of obj.
π§ Mnemonic: "Extends Ensures Eligibility" - The extends keyword ensures only eligible types can be used.
Generic Classes ποΈ
Classes can also use generics to create type-safe data structures and containers.
class Box<T> {
private contents: T;
constructor(value: T) {
this.contents = value;
}
getValue(): T {
return this.contents;
}
setValue(value: T): void {
this.contents = value;
}
}
const numberBox = new Box<number>(123);
const stringBox = new Box<string>("hello");
numberBox.setValue(456); // β
Works
numberBox.setValue("text"); // β Error: string not assignable to number
Real-world example: A generic Stack data structure
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
console.log(numberStack.pop()); // 2
Generic Interfaces π
Interfaces can be generic, allowing you to define flexible contracts:
interface Repository<T> {
getById(id: string): T | undefined;
getAll(): T[];
add(item: T): void;
remove(id: string): boolean;
}
interface User {
id: string;
name: string;
email: string;
}
class UserRepository implements Repository<User> {
private users: User[] = [];
getById(id: string): User | undefined {
return this.users.find(u => u.id === id);
}
getAll(): User[] {
return [...this.users];
}
add(user: User): void {
this.users.push(user);
}
remove(id: string): boolean {
const index = this.users.findIndex(u => u.id === id);
if (index !== -1) {
this.users.splice(index, 1);
return true;
}
return false;
}
}
Generic Type Aliases π
Type aliases can also be generic:
type ApiResponse<T> = {
data: T;
status: number;
message: string;
};
type UserResponse = ApiResponse<User>;
type ProductResponse = ApiResponse<Product[]>;
const response: UserResponse = {
data: { id: "1", name: "Alice", email: "alice@example.com" },
status: 200,
message: "Success"
};
Default Generic Types π¨
You can provide default values for generic type parameters:
interface Config<T = string> {
value: T;
label: string;
}
const config1: Config = { value: "default", label: "Label" }; // T defaults to string
const config2: Config<number> = { value: 42, label: "Number" }; // T explicitly set to number
Built-in Generic Utility Types π οΈ
TypeScript provides powerful built-in generic utility types:
| Utility Type | Purpose | Example |
|---|---|---|
Partial<T> |
Makes all properties optional | Partial<User> allows any subset of User properties |
Required<T> |
Makes all properties required | Required<User> requires all User properties |
Readonly<T> |
Makes all properties readonly | Readonly<User> prevents property modification |
Pick<T, K> |
Selects specific properties | Pick<User, 'name' | 'email'> creates type with only those properties |
Omit<T, K> |
Excludes specific properties | Omit<User, 'password'> creates User without password |
Record<K, T> |
Creates object type with keys K and values T | Record<string, number> creates string-to-number map |
Example usage:
interface User {
id: string;
name: string;
email: string;
password: string;
}
type PartialUser = Partial<User>; // All properties optional
type PublicUser = Omit<User, "password">; // User without password
type UserPreview = Pick<User, "id" | "name">; // Only id and name
type StringMap = Record<string, string>; // { [key: string]: string }
Detailed Examples with Explanations π
Example 1: Generic API Fetch Function π
Let's build a type-safe API fetcher using generics:
async function fetchData<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data as T;
}
// Usage with different types
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
interface Comment {
id: number;
postId: number;
name: string;
email: string;
body: string;
}
// TypeScript knows the exact return type!
const posts = await fetchData<Post[]>("https://api.example.com/posts");
const comment = await fetchData<Comment>("https://api.example.com/comments/1");
// Now we have full type safety:
console.log(posts[0].title); // β
TypeScript knows this exists
console.log(comment.email); // β
TypeScript knows this exists
Why this works: The generic parameter T captures the expected response type, and TypeScript ensures all downstream code respects that type. You get autocomplete and compile-time errors if you access non-existent properties.
Example 2: Generic Cache Implementation ποΈ
class Cache<T> {
private store = new Map<string, { value: T; expiry: number }>();
set(key: string, value: T, ttlSeconds: number = 3600): void {
const expiry = Date.now() + ttlSeconds * 1000;
this.store.set(key, { value, expiry });
}
get(key: string): T | null {
const item = this.store.get(key);
if (!item) return null;
if (Date.now() > item.expiry) {
this.store.delete(key);
return null;
}
return item.value;
}
has(key: string): boolean {
const value = this.get(key);
return value !== null;
}
clear(): void {
this.store.clear();
}
}
// Use the cache with different types
const userCache = new Cache<User>();
const sessionCache = new Cache<{ token: string; userId: string }>();
userCache.set("user:123", { id: "123", name: "Alice", email: "alice@example.com" });
const user = userCache.get("user:123"); // Type: User | null
sessionCache.set("session:abc", { token: "xyz789", userId: "123" });
const session = sessionCache.get("session:abc"); // Type: { token: string; userId: string } | null
Why this is powerful: One cache implementation works for any data type. Each cache instance is type-safeβyou can't accidentally store a User in the sessionCache or vice versa.
Example 3: Generic Event Emitter π’
type EventMap = Record<string, any>;
class TypedEventEmitter<T extends EventMap> {
private listeners: { [K in keyof T]?: Array<(data: T[K]) => void> } = {};
on<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(callback);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
const callbacks = this.listeners[event];
if (callbacks) {
callbacks.forEach(callback => callback(data));
}
}
off<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
const callbacks = this.listeners[event];
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index !== -1) {
callbacks.splice(index, 1);
}
}
}
}
// Define your event types
interface AppEvents {
userLogin: { userId: string; timestamp: number };
userLogout: { userId: string };
dataUpdate: { collection: string; count: number };
}
const emitter = new TypedEventEmitter<AppEvents>();
// Type-safe event listeners!
emitter.on("userLogin", (data) => {
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
// TypeScript knows exactly what properties 'data' has
});
emitter.emit("userLogin", { userId: "123", timestamp: Date.now() });
// emitter.emit("userLogin", { wrong: "data" }); // β Error: wrong shape
Why this matters: Traditional event emitters use strings and lose all type safety. This generic version ensures you can only emit valid events with the correct data shape.
Example 4: Generic Form Handler π
interface FormField<T> {
value: T;
error: string | null;
touched: boolean;
}
class FormState<T extends Record<string, any>> {
private fields: { [K in keyof T]: FormField<T[K]> };
constructor(initialValues: T) {
this.fields = {} as any;
for (const key in initialValues) {
this.fields[key] = {
value: initialValues[key],
error: null,
touched: false
};
}
}
setValue<K extends keyof T>(field: K, value: T[K]): void {
this.fields[field].value = value;
this.fields[field].touched = true;
}
getValue<K extends keyof T>(field: K): T[K] {
return this.fields[field].value;
}
setError<K extends keyof T>(field: K, error: string | null): void {
this.fields[field].error = error;
}
getError<K extends keyof T>(field: K): string | null {
return this.fields[field].error;
}
getValues(): T {
const values = {} as T;
for (const key in this.fields) {
values[key] = this.fields[key].value;
}
return values;
}
}
// Usage with a specific form shape
interface LoginForm {
email: string;
password: string;
rememberMe: boolean;
}
const loginForm = new FormState<LoginForm>({
email: "",
password: "",
rememberMe: false
});
loginForm.setValue("email", "user@example.com"); // β
Type-safe
loginForm.setValue("password", "secret123"); // β
Type-safe
loginForm.setValue("rememberMe", true); // β
Type-safe
// loginForm.setValue("wrongField", "value"); // β Error: field doesn't exist
// loginForm.setValue("email", 123); // β Error: wrong type
const email = loginForm.getValue("email"); // Type: string
const remember = loginForm.getValue("rememberMe"); // Type: boolean
Why this is elegant: The form state is completely type-safe. You can't set wrong field names or wrong value types. TypeScript enforces the contract at compile time.
Common Mistakes β οΈ
1. Forgetting Type Constraints
β Wrong:
function printLength<T>(arg: T): void {
console.log(arg.length); // Error: Property 'length' does not exist on type 'T'
}
β Right:
function printLength<T extends { length: number }>(arg: T): void {
console.log(arg.length); // β
Works! T is constrained to have length
}
2. Using any Instead of Generics
β Wrong:
function wrapInArray(value: any): any[] {
return [value];
}
const result = wrapInArray(42); // Type: any[] (lost type info)
β Right:
function wrapInArray<T>(value: T): T[] {
return [value];
}
const result = wrapInArray(42); // Type: number[] (preserved type info)
3. Over-Constraining Generics
β Wrong:
function identity<T extends string | number>(arg: T): T {
return arg;
}
identity(true); // β Error: boolean not assignable
β Right:
function identity<T>(arg: T): T {
return arg;
}
identity(true); // β
Works! No unnecessary constraint
Rule of thumb: Only add constraints when you need to access specific properties or methods on the generic type.
4. Incorrect Generic Variance
β Wrong:
function merge<T>(obj1: T, obj2: T): T {
return { ...obj1, ...obj2 }; // Error: Type spread may only create object types
}
β Right:
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
5. Not Inferring Generic Types Properly
β Wrong:
const result = identity<any>(42); // Defeats the purpose of generics
β Right:
const result = identity(42); // Let TypeScript infer T as number
// or be explicit only when necessary:
const explicit = identity<number>(42);
6. Confusing Generic Classes and Generic Methods
β Wrong:
class Container {
// This creates a NEW generic parameter, unrelated to anything outside
getValue<T>(): T {
return this.value; // Error: what is this.value?
}
}
β Right:
class Container<T> {
constructor(private value: T) {}
getValue(): T {
return this.value;
}
}
π‘ Tip: Generic parameters on classes are available to all methods. Generic parameters on methods are local to that method only.
Key Takeaways π―
β Generics enable type-safe reusable code - Write once, use with many types
β
Use constraints (extends) when you need specific properties - Don't over-constrain unnecessarily
β Let TypeScript infer types when possible - Explicit type arguments are only needed when inference fails
β Generic classes are great for containers and data structures - Stack, Queue, Cache, Repository patterns
β Generic functions preserve type relationships - Input type flows through to output type
β
Utility types leverage generics powerfully - Partial<T>, Pick<T, K>, Record<K, T> etc.
β
Avoid anyβuse generics instead - Maintain type safety while staying flexible
π€ Did you know? The concept of generics originated in ML (1973) and was popularized by Java (1998) and C# (2000s). TypeScript's generics are heavily inspired by C#'s implementation, which makes sense since both were created by Anders Hejlsberg!
π Further Study
- TypeScript Handbook - Generics: https://www.typescriptlang.org/docs/handbook/2/generics.html
- Advanced TypeScript Generic Patterns: https://www.typescriptlang.org/docs/handbook/2/types-from-types.html
- TypeScript Deep Dive - Generics: https://basarat.gitbook.io/typescript/type-system/generics
π Quick Reference Card: TypeScript Generics
| Basic Generic Function | function fn<T>(arg: T): T { return arg; } |
| Multiple Type Parameters | function pair<T, U>(a: T, b: U): [T, U] |
| Generic Constraint | function fn<T extends HasLength>(arg: T) |
| Keyof Constraint | function get<T, K extends keyof T>(obj: T, key: K) |
| Generic Class | class Box<T> { constructor(private value: T) {} } |
| Generic Interface | interface Repository<T> { getAll(): T[]; } |
| Generic Type Alias | type Response<T> = { data: T; status: number; } |
| Default Generic Type | interface Config<T = string> { value: T; } |
| Partial Utility | Partial<User> - All properties optional |
| Pick Utility | Pick<User, 'id' | 'name'> - Select properties |
| Omit Utility | Omit<User, 'password'> - Exclude properties |
| Record Utility | Record<string, number> - Map type |