TypeScript Advanced Patterns: Generics, Utility Types, and Type Guards
TypeScript Advanced Patterns: Generics, Utility Types, and Type Guards
TypeScript has revolutionized JavaScript development by adding static typing. But to truly leverage its power, you need to master advanced patterns. Let's dive deep into generics, utility types, and type guards.
Why Advanced TypeScript Matters
Writing TypeScript is easy. Writing good TypeScript that's maintainable, type-safe, and elegant requires understanding advanced patterns. These patterns help you:
- 🎯 Catch bugs at compile time
- 📚 Improve code documentation
- 🔄 Enable better refactoring
- 💡 Enhance IDE autocomplete
Generics: Write Once, Use Everywhere
Generics allow you to write reusable code that works with multiple types.
Basic Generic Function
function identity<T>(arg: T): T { return arg; } const num = identity<number>(42); // type: number const str = identity<string>("hello"); // type: string const auto = identity(true); // type inferred: boolean
Generic Constraints
interface Lengthwise { length: number; } function logLength<T extends Lengthwise>(arg: T): T { console.log(arg.length); return arg; } logLength("hello"); // ✅ Works logLength([1, 2, 3]); // ✅ Works logLength({ length: 10 }); // ✅ Works // logLength(42); // ❌ Error: number doesn't have length
Generic Interfaces
interface Repository<T> { findById(id: string): Promise<T | null>; findAll(): Promise<T[]>; create(item: Omit<T, "id">): Promise<T>; update(id: string, item: Partial<T>): Promise<T>; delete(id: string): Promise<void>; } interface User { id: string; name: string; email: string; } class UserRepository implements Repository<User> { async findById(id: string): Promise<User | null> { // Implementation return null; } async findAll(): Promise<User[]> { return []; } async create(item: Omit<User, "id">): Promise<User> { const user: User = { id: "generated", ...item }; return user; } async update(id: string, item: Partial<User>): Promise<User> { // Implementation return { id, name: "", email: "" }; } async delete(id: string): Promise<void> { // Implementation } }
Utility Types: TypeScript's Swiss Army Knife
Partial<T> - Make All Properties Optional
interface User { id: string; name: string; email: string; age: number; } function updateUser(id: string, updates: Partial<User>) { // Can update any subset of properties } updateUser("1", { name: "John" }); // ✅ updateUser("2", { email: "new@email.com" }); // ✅ updateUser("3", { name: "Jane", age: 30 }); // ✅
Pick<T, K> - Select Specific Properties
type UserPreview = Pick<User, "id" | "name">; const preview: UserPreview = { id: "1", name: "John" // email and age not required };
Omit<T, K> - Exclude Specific Properties
type UserCreate = Omit<User, "id">; const newUser: UserCreate = { name: "John", email: "john@example.com", age: 30 // id is excluded };
Record<K, T> - Create Object Type with Specific Keys
type Role = "admin" | "user" | "guest"; type Permissions = Record<Role, string[]>; const permissions: Permissions = { admin: ["read", "write", "delete"], user: ["read", "write"], guest: ["read"] };
ReturnType<T> - Extract Function Return Type
function createUser() { return { id: "123", name: "John", email: "john@example.com" }; } type User = ReturnType<typeof createUser>; // type User = { id: string; name: string; email: string; }
Type Guards: Runtime Type Safety
typeof Type Guard
function processValue(value: string | number) { if (typeof value === "string") { // TypeScript knows value is string here return value.toUpperCase(); } else { // TypeScript knows value is number here return value.toFixed(2); } }
instanceof Type Guard
class Dog { bark() { console.log("Woof!"); } } class Cat { meow() { console.log("Meow!"); } } function makeSound(animal: Dog | Cat) { if (animal instanceof Dog) { animal.bark(); // TypeScript knows it's a Dog } else { animal.meow(); // TypeScript knows it's a Cat } }
Custom Type Guards
interface Fish { swim: () => void; } interface Bird { fly: () => void; } // User-defined type guard function isFish(pet: Fish | Bird): pet is Fish { return (pet as Fish).swim !== undefined; } function move(pet: Fish | Bird) { if (isFish(pet)) { pet.swim(); // TypeScript knows pet is Fish } else { pet.fly(); // TypeScript knows pet is Bird } }
Discriminated Unions
interface Success { type: "success"; data: any; } interface Error { type: "error"; message: string; } type Result = Success | Error; function handleResult(result: Result) { switch (result.type) { case "success": console.log(result.data); // TypeScript knows it's Success break; case "error": console.log(result.message); // TypeScript knows it's Error break; } }
Advanced Pattern: Conditional Types
type IsString<T> = T extends string ? true : false; type A = IsString<string>; // true type B = IsString<number>; // false // More practical example type NonNullable<T> = T extends null | undefined ? never : T; type A = NonNullable<string | null>; // string type B = NonNullable<number | undefined>; // number
Advanced Pattern: Mapped Types
type Readonly<T> = { readonly [P in keyof T]: T[P]; }; type Optional<T> = { [P in keyof T]?: T[P]; }; interface User { id: string; name: string; } type ReadonlyUser = Readonly<User>; // { readonly id: string; readonly name: string; } type OptionalUser = Optional<User>; // { id?: string; name?: string; }
Real-World Example: API Response Handler
interface ApiResponse<T> { data?: T; error?: string; status: number; } // Generic fetch wrapper async function fetchApi<T>(url: string): Promise<ApiResponse<T>> { try { const response = await fetch(url); const data = await response.json(); return { data, status: response.status }; } catch (error) { return { error: error instanceof Error ? error.message : "Unknown error", status: 500 }; } } // Type-safe usage interface User { id: string; name: string; } async function getUser(id: string) { const response = await fetchApi<User>(`/api/users/${id}`); if (response.error) { console.error(response.error); return null; } return response.data; // TypeScript knows this is User | undefined }
Best Practices
- Use Generics for Reusability: Write once, use with many types
- Leverage Utility Types: Don't reinvent the wheel
- Create Custom Type Guards: Improve runtime type safety
- Use Discriminated Unions: For complex state management
- Keep Types Simple: Don't over-engineer
Common Pitfalls to Avoid
❌ Too Generic
function process<T>(data: T): T { // Too generic, loses type information return data; }
✅ Properly Constrained
function process<T extends { id: string }>(data: T): T { console.log(data.id); // Type-safe access return data; }
Conclusion
Advanced TypeScript patterns transform your code from "typed JavaScript" to truly type-safe, maintainable applications. Master these patterns, and you'll write better code with fewer bugs.
Start small, practice often, and gradually incorporate these patterns into your projects. Your future self (and teammates) will thank you! 🚀