Паттерны проектирования в TypeScript: когда и зачем
Представьте, что вы строите дом. Можно класть кирпичи как попало, и он, скорее всего, не развалится. Но будет ли в нём удобно жить? Вряд ли. Архитектура программного обеспечения — та же стройка, только вместо кирпичей — классы и функции. Паттерны проектирования в TypeScript — это проверенные чертежи для типовых архитектурных задач. Но, как и любой инструмент, их нужно применять с умом.
В этой статье мы не будем пересказывать сухую теорию из книг GoF (Банды четырёх). Вместо этого разберём, какие паттерны проектирования TypeScript реально нужны в современной разработке, когда они спасают проект, а когда только добавляют головной боли. Поехали!
Почему TypeScript меняет правила игры?
Прежде чем нырять в паттерны, давайте вспомним главную фишку TypeScript — статическую типизацию. В JavaScript мы часто использовали паттерны, чтобы компенсировать динамическую природу языка. Например, Фабричный метод (Factory Method) помогал скрыть сложность создания объектов. В TypeScript эта сложность часто решается просто интерфейсами и дженериками.
Но это не значит, что паттерны умерли. Наоборот, TypeScript делает их более выразительными и безопасными. Компилятор проверяет, что вы правильно реализовали структуру, и не даёт сделать глупую ошибку. Вот где TypeScript раскрывает силу паттернов GoF.
Топ-5 паттернов, которые вы будете использовать чаще всего
1. Singleton (Одиночка) — когда один — хорошо, а два — лишние
Когда использовать: Вам нужен ровно один экземпляр класса для всего приложения. Классические примеры — логгер, конфигурация приложения, подключение к базе данных.
Зачем в TypeScript: TypeScript позволяет сделать Singleton потокобезопасным и типобезопасным. Без него вы рискуете плодить десятки объектов, которые будут тратить память и путать состояние.
Пример кода:
class DatabaseConnection {
private static instance: DatabaseConnection;
private connectionString: string;
private constructor(connectionString: string) {
this.connectionString = connectionString;
// Имитация подключения
console.log(`Подключение к ${connectionString}`);
}
static getInstance(connectionString: string): DatabaseConnection {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection(connectionString);
}
return DatabaseConnection.instance;
}
query(sql: string): void {
console.log(`Выполнение запроса: ${sql}`);
}
}
// Использование
const db = DatabaseConnection.getInstance('postgres://localhost:5432/mydb');
db.query('SELECT * FROM users');
// Попытка создать ещё одно подключение вернёт тот же экземпляр
const db2 = DatabaseConnection.getInstance('postgres://localhost:5432/mydb');
console.log(db === db2); // true
Антипаттерн: Не делайте Singleton ради Singleton. Если объект может существовать в нескольких экземплярах с разными состояниями (например, разные подключения к разным БД), то Singleton — зло.
2. Factory Method (Фабричный метод) — создание объектов без боли
Когда использовать: Вы не знаете заранее, какой именно объект нужно создать. Или процесс создания сложный и требует логики.
Зачем в TypeScript: Фабричный метод отлично сочетается с интерфейсами и дженериками. Вы получаете гибкость без потери типов.
Пример кода:
// Абстрактный продукт
interface Notification {
send(message: string): void;
}
// Конкретные продукты
class EmailNotification implements Notification {
send(message: string): void {
console.log(`Отправка email: ${message}`);
}
}
class SMSNotification implements Notification {
send(message: string): void {
console.log(`Отправка SMS: ${message}`);
}
}
// Фабрика
class NotificationFactory {
createNotification(type: 'email' | 'sms'): Notification {
switch (type) {
case 'email':
return new EmailNotification();
case 'sms':
return new SMSNotification();
default:
throw new Error('Неизвестный тип уведомления');
}
}
}
// Использование
const factory = new NotificationFactory();
const notification = factory.createNotification('email');
notification.send('Ваш заказ подтверждён!');
Совет: Если у вас всего два типа, фабрика может быть избыточной. Но как только типов становится больше трёх, паттерн Factory Method окупается сторицей.
3. Observer (Наблюдатель) — реактивность на коленке
Когда использовать: Один объект (субъект) должен уведомлять множество других (наблюдателей) об изменениях. Например, события UI, обновление данных в реальном времени.
Зачем в TypeScript: TypeScript позволяет строго типизировать события, что снижает количество багов. В JavaScript вы часто ловили ошибки "событие не обработано", здесь компилятор подскажет.
Пример кода:
// Тип события
interface Event {
type: string;
data: unknown;
}
// Наблюдатель
type Observer = (event: Event) => void;
// Субъект
class EventBus {
private observers: Observer[] = [];
subscribe(observer: Observer): void {
this.observers.push(observer);
}
unsubscribe(observer: Observer): void {
this.observers = this.observers.filter(obs => obs !== observer);
}
emit(event: Event): void {
this.observers.forEach(observer => observer(event));
}
}
// Использование
const bus = new EventBus();
const logObserver: Observer = (event) => {
console.log(`Событие: ${event.type}`, event.data);
};
bus.subscribe(logObserver);
bus.emit({ type: 'USER_LOGIN', data: { userId: 1 } });
// Вывод: Событие: USER_LOGIN { userId: 1 }
Важно: Не злоупотребляйте Observer. Если у вас сотни подписчиков, производительность может упасть. И не забывайте отписываться, чтобы избежать утечек памяти.
4. Strategy (Стратегия) — алгоритмы на замену
Когда использовать: У вас есть несколько алгоритмов для решения одной задачи, и вы хотите переключаться между ними на лету. Например, разные способы сортировки, валидации, расчёта скидок.
Зачем в TypeScript: Стратегия превращает раздутые if-else цепочки в чистый, расширяемый код. Типизация делает каждую стратегию предсказуемой.
Пример кода:
// Интерфейс стратегии
interface DiscountStrategy {
calculate(price: number): number;
}
// Конкретные стратегии
class NoDiscount implements DiscountStrategy {
calculate(price: number): number {
return price;
}
}
class PercentageDiscount implements DiscountStrategy {
constructor(private percent: number) {}
calculate(price: number): number {
return price * (1 - this.percent / 100);
}
}
class FixedDiscount implements DiscountStrategy {
constructor(private amount: number) {}
calculate(price: number): number {
return Math.max(0, price - this.amount);
}
}
// Контекст
class ShoppingCart {
private strategy: DiscountStrategy;
constructor(strategy: DiscountStrategy) {
this.strategy = strategy;
}
setStrategy(strategy