TypeScript has evolved from a simple type checker to a powerful language that enables developers to write more maintainable and scalable code. Let's explore advanced patterns and best practices that will take your TypeScript skills to the next level.
Conditional types allow you to create types that depend on other types:
type NonNullable<T> = T extends null | undefined ? never : T;
type Result = NonNullable<string | null | undefined>; // string
// Advanced conditional type
type ArrayElement<T> = T extends Array<infer U> ? U : never;
type StringArray = string[];
type Element = ArrayElement<StringArray>; // string
Transform existing types into new ones:
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
type User = {
id: number;
name: string;
email: string;
};
type ReadonlyUser = Readonly<User>;
// {
// readonly id: number;
// readonly name: string;
// readonly email: string;
// }
Create string literal types with dynamic parts:
type EventName<T extends string> = `${T}Changed`;
type UserEvents = EventName<'user'>; // "userChanged"
type ApiEndpoint<T extends string> = `/api/${T}`;
type UserEndpoint = ApiEndpoint<'users'>; // "/api/users"
Create objects without specifying their exact class:
interface Animal {
makeSound(): void;
}
class Dog implements Animal {
makeSound() {
console.log('Woof!');
}
}
class Cat implements Animal {
makeSound() {
console.log('Meow!');
}
}
class AnimalFactory {
static createAnimal(type: 'dog' | 'cat'): Animal {
switch (type) {
case 'dog':
return new Dog();
case 'cat':
return new Cat();
default:
throw new Error('Unknown animal type');
}
}
}
const dog = AnimalFactory.createAnimal('dog');
dog.makeSound(); // "Woof!"
Ensure a class has only one instance:
class Database {
private static instance: Database;
private constructor() {}
static getInstance(): Database {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
query(sql: string): any {
console.log(`Executing: ${sql}`);
}
}
const db1 = Database.getInstance();
const db2 = Database.getInstance();
console.log(db1 === db2); // true
Implement event-driven architecture:
interface Observer {
update(data: any): void;
}
class Subject {
private observers: Observer[] = [];
attach(observer: Observer): void {
this.observers.push(observer);
}
detach(observer: Observer): void {
const index = this.observers.indexOf(observer);
if (index > -1) {
this.observers.splice(index, 1);
}
}
notify(data: any): void {
this.observers.forEach(observer => observer.update(data));
}
}
class Logger implements Observer {
update(data: any): void {
console.log(`Logging: ${JSON.stringify(data)}`);
}
}
class EmailNotifier implements Observer {
update(data: any): void {
console.log(`Sending email: ${data.message}`);
}
}
const subject = new Subject();
subject.attach(new Logger());
subject.attach(new EmailNotifier());
subject.notify({ message: 'User logged in' });
Limit what types can be used with generics:
interface HasLength {
length: number;
}
function logLength<T extends HasLength>(item: T): void {
console.log(`Length: ${item.length}`);
}
logLength('hello'); // Length: 5
logLength([1, 2, 3]); // Length: 3
// logLength(123); // Error: number doesn't have 'length' property
Create reusable type utilities:
// Make all properties optional
type Partial<T> = {
[P in keyof T]?: T[P];
};
// Make all properties required
type Required<T> = {
[P in keyof T]-?: T[P];
};
// Pick specific properties
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
// Omit specific properties
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
// Example usage
interface User {
id: number;
name: string;
email?: string;
}
type UserUpdate = Partial<User>; // All properties optional
type UserRequired = Required<User>; // All properties required
type UserBasic = Pick<User, 'id' | 'name'>; // Only id and name
type UserWithoutId = Omit<User, 'id'>; // Everything except id
Handle errors without exceptions:
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return { success: false, error: 'Division by zero' };
}
return { success: true, data: a / b };
}
function handleDivision(a: number, b: number): void {
const result = divide(a, b);
if (result.success) {
console.log(`Result: ${result.data}`);
} else {
console.error(`Error: ${result.error}`);
}
}
Type-safe error handling:
class ValidationError extends Error {
constructor(message: string, public field: string) {
super(message);
this.name = 'ValidationError';
}
}
class NetworkError extends Error {
constructor(message: string, public statusCode: number) {
super(message);
this.name = 'NetworkError';
}
}
function isValidationError(error: unknown): error is ValidationError {
return error instanceof ValidationError;
}
function isNetworkError(error: unknown): error is NetworkError {
return error instanceof NetworkError;
}
async function fetchUser(id: string): Promise<User> {
try {
if (!id) {
throw new ValidationError('ID is required', 'id');
}
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new NetworkError('Failed to fetch user', response.status);
}
return await response.json();
} catch (error) {
if (isValidationError(error)) {
console.error(`Validation error in field ${error.field}: ${error.message}`);
} else if (isNetworkError(error)) {
console.error(`Network error ${error.statusCode}: ${error.message}`);
} else {
console.error('Unknown error:', error);
}
throw error;
}
}
Reduce bundle size by importing only types:
// Instead of
import { User, createUser } from './user';
// Use type-only imports
import type { User } from './user';
import { createUser } from './user';
Make objects deeply readonly:
const config = {
api: {
baseUrl: 'https://api.example.com',
timeout: 5000,
},
features: {
darkMode: true,
notifications: false,
},
} as const;
// Type is now deeply readonly
type Config = typeof config;
Ensure your tests are type-safe:
import { describe, it, expect } from 'vitest';
interface TestUser {
id: number;
name: string;
email: string;
}
function validateUser(user: unknown): user is TestUser {
return (
typeof user === 'object' &&
user !== null &&
'id' in user &&
'name' in user &&
'email' in user &&
typeof (user as any).id === 'number' &&
typeof (user as any).name === 'string' &&
typeof (user as any).email === 'string'
);
}
describe('User validation', () => {
it('should validate correct user object', () => {
const user = { id: 1, name: 'John', email: 'john@example.com' };
expect(validateUser(user)).toBe(true);
});
it('should reject invalid user object', () => {
const invalidUser = { id: '1', name: 'John' }; // Missing email, wrong id type
expect(validateUser(invalidUser)).toBe(false);
});
});
any, use specific union typesTypeScript's advanced features enable you to write more robust, maintainable, and self-documenting code. By mastering these patterns and best practices, you'll be able to build complex applications with confidence and catch errors at compile time rather than runtime.
Remember, TypeScript is not just about adding types to JavaScript—it's about creating a better development experience and building more reliable software.