TypeScript Advanced Patterns: Generics, Utility Types, and Type Guards
TypeScript's type system is incredibly powerful, but many developers only scratch the surface. In this guide, we'll dive deep into advanced TypeScript patterns that will make your code more type-safe, reusable, and maintainable.
Why Advanced TypeScript Matters
Using advanced TypeScript patterns helps you:
- 🛡️ Catch bugs at compile time instead of runtime
- 🔄 Write more reusable code with generics
- 📝 Better IDE support with accurate autocomplete
- 🎯 Self-documenting code through types
- ⚡ Improved refactoring with type safety
Generics: The Foundation
Generics allow you to write flexible, reusable code while maintaining type safety.
Basic Generic Function
// Without generics - not type-safe
function identity(arg: any): any {
return arg;
}
// With generics - type-safe
function identity<T>(arg: T): T {
return arg;
}
const num = identity<number>(42); // number
const str = identity<string>("hello"); // string
const auto = identity(true); // boolean (inferred)
Generic Constraints
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(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 Classes
class DataStore<T> {
private data: T[] = [];
add(item: T): void {
this.data.push(item);
}
get(index: number): T | undefined {
return this.data[index];
}
getAll(): T[] {
return [...this.data];
}
}
const numberStore = new DataStore<number>();
numberStore.add(1);
numberStore.add(2);
// numberStore.add("3"); // ❌ Error
const userStore = new DataStore<User>();
userStore.add({ id: 1, name: "John" });
Utility Types: Built-in Type Transformations
TypeScript provides powerful utility types for common type transformations.
Partial
Makes all properties optional:
interface User {
id: number;
name: string;
email: string;
age: number;
}
function updateUser(id: number, updates: Partial<User>) {
// Only update provided fields
}
updateUser(1, { name: "John" }); // ✅ Valid
updateUser(2, { email: "john@email.com" }); // ✅ Valid
Required
Makes all properties required:
interface Config {
host?: string;
port?: number;
timeout?: number;
}
function createConnection(config: Required<Config>) {
// All properties must be provided
}
createConnection({
host: "localhost",
port: 3000,
timeout: 5000
}); // ✅ Valid
Pick<T, K>
Creates a type with only selected properties:
interface User {
id: number;
name: string;
email: string;
password: string;
createdAt: Date;
}
type UserPreview = Pick<User, 'id' | 'name' | 'email'>;
const preview: UserPreview = {
id: 1,
name: "John",
email: "john@email.com"
// password not needed
};
Omit<T, K>
Creates a type without specified properties:
type UserWithoutPassword = Omit<User, 'password'>;
const publicUser: UserWithoutPassword = {
id: 1,
name: "John",
email: "john@email.com",
createdAt: new Date()
// password omitted
};
Record<K, T>
Creates an object type with specific keys and value types:
type Role = 'admin' | 'user' | 'guest';
const permissions: Record<Role, string[]> = {
admin: ['read', 'write', 'delete'],
user: ['read', 'write'],
guest: ['read']
};
Advanced Type Guards
Type guards help TypeScript narrow down types at runtime.
typeof Type Guards
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 Guards
class Dog {
bark() {
console.log("Woof!");
}
}
class Cat {
meow() {
console.log("Meow!");
}
}
function makeSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
animal.bark();
} else {
animal.meow();
}
}
Custom Type Guards
interface Fish {
swim: () => void;
}
interface Bird {
fly: () => void;
}
// Custom 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();
} else {
pet.fly();
}
}
Discriminated Unions
interface Circle {
kind: 'circle';
radius: number;
}
interface Square {
kind: 'square';
sideLength: number;
}
interface Rectangle {
kind: 'rectangle';
width: number;
height: number;
}
type Shape = Circle | Square | Rectangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
}
}
Conditional Types
Conditional types allow you to create types based on conditions.
Basic Conditional Type
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
Practical Example: Extract Return Type
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "John" };
}
type User = ReturnType<typeof getUser>; // { id: number; name: string }
Mapped Types
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
interface User {
id: number;
name: string;
}
type ReadonlyUser = Readonly<User>;
// {
// readonly id: number;
// readonly name: string;
// }
Real-World Examples
API Response Handler
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
const response = await fetch(url);
return response.json();
}
// Usage
interface User {
id: number;
name: string;
}
const userResponse = await fetchData<User>('/api/user/1');
console.log(userResponse.data.name); // Type-safe!
Form Validation
type ValidationRule<T> = {
[K in keyof T]: (value: T[K]) => string | null;
};
interface LoginForm {
email: string;
password: string;
}
const validationRules: ValidationRule<LoginForm> = {
email: (value) => {
return value.includes('@') ? null : 'Invalid email';
},
password: (value) => {
return value.length >= 8 ? null : 'Password too short';
}
};
Event Emitter
type EventMap = {
'user:login': { userId: number; timestamp: Date };
'user:logout': { userId: number };
'post:created': { postId: number; authorId: number };
};
class TypedEventEmitter<T extends Record<string, any>> {
private listeners: {
[K in keyof T]?: Array<(data: T[K]) => void>;
} = {};
on<K extends keyof T>(event: K, callback: (data: T[K]) => void) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(callback);
}
emit<K extends keyof T>(event: K, data: T[K]) {
this.listeners[event]?.forEach(callback => callback(data));
}
}
const emitter = new TypedEventEmitter<EventMap>();
emitter.on('user:login', (data) => {
console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
emitter.emit('user:login', {
userId: 1,
timestamp: new Date()
});
Best Practices
1. Use Type Inference When Possible
// ❌ Redundant
const numbers: number[] = [1, 2, 3];
// ✅ Better
const numbers = [1, 2, 3]; // TypeScript infers number[]
2. Prefer Interfaces for Objects
// ✅ Good for objects
interface User {
id: number;
name: string;
}
// ✅ Good for unions
type Status = 'pending' | 'approved' | 'rejected';
3. Use const Assertions
const config = {
apiUrl: 'https://api.example.com',
timeout: 5000
} as const;
// config.apiUrl is now 'https://api.example.com' (literal type)
// not just string
4. Avoid any, Use unknown
// ❌ Bad
function process(data: any) {
return data.value;
}
// ✅ Better
function process(data: unknown) {
if (typeof data === 'object' && data !== null && 'value' in data) {
return (data as { value: any }).value;
}
}
Common Pitfalls
1. Over-engineering Types
// ❌ Too complex
type ComplexType<T> = T extends Array<infer U>
? U extends object
? { [K in keyof U]: U[K] extends Function ? never : U[K] }
: never
: never;
// ✅ Keep it simple
type SimpleType<T> = T extends Array<infer U> ? U : never;
2. Ignoring Strict Mode
Always enable strict mode in tsconfig.json:
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitAny": true
}
}
Conclusion
Advanced TypeScript patterns unlock the full potential of the type system. By mastering generics, utility types, and type guards, you can write code that is:
- More maintainable
- Easier to refactor
- Self-documenting
- Less prone to bugs
Start incorporating these patterns into your projects today, and watch your code quality improve!
Resources
Enjoyed this article?
Explore more deep dives into architecture, performance, and modern .NET.