Understanding TypeScript Generics: From Basics to Advanced Patterns 🚀
Hey TypeScript enthusiasts! 👋 Ready to level up your type-safety game? Today, we're diving deep into one of TypeScript's most powerful features - Generics. Don't worry if they seem intimidating at first; by the end of this article, you'll be wielding them like a pro!
What Are Generics? 🤔
Think of Generics as type-level functions - they let you write code that works with multiple types while maintaining full type safety. Instead of hardcoding specific types, you can create flexible, reusable components that work with any type you throw at them.
Your First Generic Function 🎯
Let's start with something simple - a function that returns whatever you pass to it:
function echo<T>(value: T): T {
return value;
}
// TypeScript knows exactly what types these are!
const str = echo("Hello"); // type: string
const num = echo(42); // type: number
const obj = echo({ x: 10, y: 20 }); // type: { x: number, y: number }
What's happening here? The <T>
tells TypeScript "this is a generic type parameter." When you call the function, TypeScript automatically figures out what T
should be based on what you pass in. No type casting needed!
Generic Interfaces: Building Type-Safe Data Structures 🏗️
Let's create a type-safe container that can hold any type of value:
interface Container<T> {
value: T;
timestamp: Date;
log(): void;
}
class DataContainer<T> implements Container<T> {
constructor(public value: T, public timestamp: Date = new Date()) {}
log(): void {
console.log(`[${this.timestamp.toISOString()}] Value: ${JSON.stringify(this.value)}`);
}
}
// TypeScript knows these are different types!
const numberContainer = new DataContainer(42);
const stringContainer = new DataContainer("Hello");
Advanced Generic Patterns 🔥
1. Constraining Type Parameters
Sometimes you want to restrict what types can be used with your generic. The extends
keyword lets you do this:
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): void {
console.log(`Length: ${item.length}`);
}
// These work fine!
logLength("Hello"); // strings have length
logLength([1, 2, 3]); // arrays have length
logLength({ length: 10 }); // objects with length property work too
// This would be a type error!
// logLength(42); // numbers don't have a length property
2. Generic Type Mapping
Want to transform every property in a type? Generic mapped types are your friend:
type ReadOnly<T> = {
readonly [K in keyof T]: T[K];
};
interface User {
id: number;
name: string;
preferences: { theme: string };
}
// Creates a new type where all properties are readonly
type ReadOnlyUser = ReadOnly<User>;
const user: ReadOnlyUser = {
id: 1,
name: "Alice",
preferences: { theme: "dark" }
};
// TypeScript error: Cannot modify readonly property
// user.name = "Bob";
3. Conditional Types with Generics
You can even make types that change based on conditions:
type IsString<T> = T extends string ? true : false;
// TypeScript knows these types at compile time!
type A = IsString<"hello">; // type: true
type B = IsString<42>; // type: false
// Real-world example: Extract nullable types
type NonNullable<T> = T extends null | undefined ? never : T;
type C = NonNullable<string | null | undefined>; // type: string
Practical Use Cases 💡
1. Type-Safe API Responses
interface ApiResponse<T> {
data: T;
status: number;
timestamp: string;
error?: string;
}
interface User {
id: number;
name: string;
}
async function fetchUser(id: number): Promise<ApiResponse<User>> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// TypeScript knows the exact shape of userData!
const userData = await fetchUser(1);
console.log(userData.data.name); // Fully type-safe!
2. Type-Safe Event Handling
type EventMap = {
click: { x: number; y: number };
change: { oldValue: string; newValue: string };
submit: { data: Record<string, unknown> };
}
class TypedEventEmitter {
private listeners: Record<string, Function[]> = {};
on<K extends keyof EventMap>(event: K, callback: (data: EventMap[K]) => void) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]) {
this.listeners[event]?.forEach(callback => callback(data));
}
}
const emitter = new TypedEventEmitter();
// TypeScript knows exactly what shape each event should have!
emitter.on('click', ({ x, y }) => console.log(`Clicked at ${x}, ${y}`));
emitter.on('change', ({ oldValue, newValue }) => console.log(`Changed from ${oldValue} to ${newValue}`));
Best Practices 📝
- Keep It Simple: Start with a single type parameter and add more only when needed.
- Use Descriptive Names: Instead of just
T
, use names likeTData
orTResponse
when the meaning isn't obvious. - Constrain When Possible: Use
extends
to make your generics more predictable and catch errors earlier. - Document Your Generics: Add JSDoc comments explaining what types are expected and why.
Wrap-Up 🎉
Generics might seem complex at first, but they're an incredibly powerful tool for building type-safe, reusable code. Start with the basics and gradually work your way up to more advanced patterns. Before you know it, you'll be using generics to write more robust and maintainable TypeScript code!
Have questions or want to share your own generic patterns? Drop them in the comments below! Let's learn together. 🙌
Happy coding! 💻✨