Design Patterns in TypeScript: A Comprehensive Guide
Что это такое и зачем? (аналогия из реальной жизни)
Представь, что ты строишь дом. Ты можешь каждый раз изобретать велосипед: придумывать, как соединять брёвна, как делать крышу, как проводить электричество. А можешь взять готовые чертежи и решения, которые уже проверили тысячи строителей до тебя. Паттерны проектирования — это и есть такие готовые «чертежи» для программистов.
В реальной жизни мы постоянно используем шаблоны. Например, когда ты заказываешь пиццу, ты не идёшь на кухню и не месишь тесто сам — ты просто говоришь оператору: «Маргариту, пожалуйста». Это и есть паттерн «Фабрика»: ты не знаешь, как именно готовится пицца, но получаешь готовый результат.
Паттерны проектирования — это проверенные способы решения типовых задач в программировании. Они не привязаны к конкретному языку, но в TypeScript есть свои особенности их реализации. Зачем они нужны?
- Экономия времени — не нужно изобретать решение с нуля
- Понятность — другие программисты сразу узнают знакомую структуру
- Надёжность — паттерны проверены годами использования
- Гибкость — код легче менять и расширять
Важно: Паттерны — это не готовый код, а концепция. Ты можешь реализовать один и тот же паттерн по-разному, главное — соблюдать общую идею.
Теория: как это работает изнутри
Все паттерны делятся на три большие группы:
| Группа | Что делает | Примеры |
|---|---|---|
| Порождающие | Отвечают за создание объектов | Singleton, Factory, Builder |
| Структурные | Организуют связи между объектами | Adapter, Decorator, Facade |
| Поведенческие | Управляют взаимодействием | Observer, Strategy, Command |
В основе любого паттерна лежат принципы объектно-ориентированного программирования (ООП):
- Инкапсуляция — скрываем внутренние детали
- Наследование — создаём новые классы на основе старых
- Полиморфизм — один интерфейс, разная реализация
TypeScript добавляет к этому строгую типизацию, что делает паттерны ещё надёжнее. Ошибки, которые в JavaScript проявились бы только во время выполнения, TypeScript отлавливает на этапе компиляции.
Синтаксис в TypeScript: все формы и варианты
TypeScript предоставляет несколько ключевых возможностей, которые делают реализацию паттернов удобнее:
| Возможность TypeScript | Как помогает в паттернах |
|---|---|
| Интерфейсы (interface) | Определяют контракт для классов — основа для Factory, Strategy |
| Абстрактные классы | Позволяют задать общую логику с обязательными методами — для Template Method |
| Дженерики (Generics) | Создают универсальные компоненты — для Repository, Iterator |
| Модификаторы доступа (private, protected) | Реализуют инкапсуляцию — для Singleton, Facade |
| Enum (перечисления) | Удобны для состояний — для State, Strategy |
Рассмотрим базовый синтаксис, который понадобится для всех паттернов:
// Базовый интерфейс — как договорённость
interface Animal {
makeSound(): string; // Любой класс с этим интерфейсом обязан иметь этот метод
}
// Реализация интерфейса
class Dog implements Animal {
makeSound(): string {
return "Гав!";
}
}
// Абстрактный класс — частичная реализация
abstract class Shape {
protected color: string; // protected — доступно только наследникам
constructor(color: string) {
this.color = color;
}
abstract calculateArea(): number; // наследники обязаны реализовать
getColor(): string { // общий метод для всех
return this.color;
}
}
// Дженерик — универсальный тип
class Box<T> {
private content: T;
constructor(content: T) {
this.content = content;
}
getContent(): T {
return this.content;
}
}
Примеры кода — от простого к сложному
Пример 1: Singleton (Одиночка) — простейший паттерн
// Singleton гарантирует, что у класса есть только один экземпляр
class DatabaseConnection {
private static instance: DatabaseConnection; // храним единственный экземпляр
private connectionString: string;
// Приватный конструктор — никто не может создать объект через new
private constructor(connectionString: string) {
this.connectionString = connectionString;
console.log("Создано новое подключение к БД");
}
// Статический метод — единственный способ получить экземпляр
static getInstance(connectionString: string): DatabaseConnection {
if (!DatabaseConnection.instance) {
// Если экземпляра нет — создаём
DatabaseConnection.instance = new DatabaseConnection(connectionString);
}
return DatabaseConnection.instance;
}
query(sql: string): void {
console.log(`Выполняем запрос: ${sql} к ${this.connectionString}`);
}
}
// Использование
const db1 = DatabaseConnection.getInstance("localhost:5432");
const db2 = DatabaseConnection.getInstance("localhost:5432");
console.log(db1 === db2); // true — это один и тот же объект
db1.query("SELECT * FROM users");
Запомни: Singleton используют для подключений к базе данных, логгеров, конфигураций — всего, что должно быть в единственном экземпляре.
Пример 2: Factory Method (Фабричный метод)
// Фабрика — создаёт объекты, не указывая конкретный класс
interface Transport {
deliver(): string;
}
class Truck implements Transport {
deliver(): string {
return "Доставка по суше в грузовике";
}
}
class Ship implements Transport {
deliver(): string {
return "Доставка по морю на корабле";
}
}
// Класс-создатель с фабричным методом
abstract class Logistics {
// Фабричный метод — наследники решают, какой объект создавать
abstract createTransport(): Transport;
planDelivery(): string {
const transport = this.createTransport(); // вызываем фабричный метод
return `Логистика: ${transport.deliver()}`;
}
}
class RoadLogistics extends Logistics {
createTransport(): Transport {
return new Truck(); // создаём грузовик
}
}
class SeaLogistics extends Logistics {
createTransport(): Transport {
return new Ship(); // создаём корабль
}
}
// Использование
const logistics = new RoadLogistics();
console.log(logistics.planDelivery()); // "Логистика: Доставка по суше в грузовике"
Пример 3: Builder (Строитель)
// Builder — создаёт сложные объекты пошагово
class Pizza {
public dough: string = '';
public sauce: string = '';
public cheese: string = '';
public toppings: string[] = [];
describe(): string {
return `Пицца: ${this.dough} тесто, соус ${this.sauce}, сыр ${this.cheese}, топпинги: ${this.toppings.join(', ')}`;
}
}
// Строитель — содержит методы для каждого шага
class PizzaBuilder {
private pizza: Pizza;
constructor() {
this.pizza = new Pizza();
}
setDough(dough: string): this { // возвращаем this для цепочки вызовов
this.pizza.dough = dough;
return this;
}
setSauce(sauce: string): this {
this.pizza.sauce = sauce;
return this;
}
setCheese(cheese: string): this {
this.pizza.cheese = cheese;
return this;
}
addTopping(topping: string): this {
this.pizza.toppings.push(topping);
return this;
}
build(): Pizza {
return this.pizza; // возвращаем готовый объект
}
}
// Использование
const pizza = new PizzaBuilder()
.setDough("тонкое")
.setSauce("томатный")
.setCheese("моцарелла")
.addTopping("грибы")
.addTopping("оливки")
.build();
console.log(pizza.describe());
Пример 4: Adapter (Адаптер)
// Adapter — позволяет работать несовместимым классам вместе
// Представь: у нас есть старая система с дюймами и новая с сантиметрами
// Старый класс — работает с дюймами
class OldRuler {
getLengthInInches(): number {
return 10; // возвращает дюймы
}
}
// Новый интерфейс — ожидает сантиметры
interface NewRuler {
getLengthInCentimeters(): number;
}
// Адаптер — преобразует дюймы в сантиметры
class RulerAdapter implements NewRuler {
private oldRuler: OldRuler;
constructor(oldRuler: OldRuler) {
this.oldRuler = oldRuler;
}
getLengthInCentimeters(): number {
// 1 дюйм = 2.54 сантиметра
return this.oldRuler.getLengthInInches() * 2.54;
}
}
// Использование
const oldRuler = new OldRuler();
const adapter = new RulerAdapter(oldRuler);
console.log(`Длина в сантиметрах: ${adapter.getLengthInCentimeters()}`); // 25.4
Пример 5: Observer (Наблюдатель)
// Observer — один объект уведомляет множество других об изменениях
// Отличный пример: подписка на YouTube канал
interface Observer {
update(videoTitle: string): void; // метод, который вызывается при уведомлении
}
// Издатель (Subject) — тот, кто публикует видео
class YouTubeChannel {
private subscribers: Observer[] = []; // список подписчиков
private channelName: string;
constructor(name: string) {
this.channelName = name;
}
// Подписаться
subscribe(observer: Observer): void {
this.subscribers.push(observer);
console.log(`Новый подписчик на канале ${this.channelName}`);
}
// Отписаться
unsubscribe(observer: Observer): void {
this.subscribers = this.subscribers.filter(sub => sub !== observer);
}
// Оповестить всех подписчиков
uploadVideo(title: string): void {
console.log(`Канал ${this.channelName} опубликовал: ${title}`);
this.subscribers.forEach(sub => sub.update(title));
}
}
// Конкретный наблюдатель — подписчик
class Subscriber implements Observer {
private name: string;
constructor(name: string) {
this.name = name;
}
update(videoTitle: string): void {
console.log(`${this.name} получил уведомление: новое видео "${videoTitle}"`);
}
}
// Использование
const channel = new YouTubeChannel("IT-уроки");
const alice = new Subscriber("Алиса");
const bob = new Subscriber("Боб");
channel.subscribe(alice);
channel.subscribe(bob);
channel.uploadVideo("TypeScript для начинающих");
// Алиса и Боб получат уведомление
Пример 6: Strategy (Стратегия)
// Strategy — выбираем алгоритм на лету
// Представь: приложение для навигации выбирает маршрут
interface RouteStrategy {
calculateRoute(from: string, to: string): string;
}
class WalkingStrategy implements RouteStrategy {
calculateRoute(from: string, to: string): string {
return `Пеший маршрут от ${from} до ${to}: 2 км, 25 минут`;
}
}
class DrivingStrategy implements RouteStrategy {
calculateRoute(from: string, to: string): string {
return `Автомобильный маршрут от ${from} до ${to}: 5 км, 10 минут`;
}
}
class BicycleStrategy implements RouteStrategy {
calculateRoute(from: string, to: string): string {
return `Веломаршрут от ${from} до ${to}: 3 км, 15 минут`;
}
}
// Контекст — использует стратегию
class Navigator {
private strategy: RouteStrategy;
constructor(strategy: RouteStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: RouteStrategy): void {
this.strategy = strategy; // можно сменить стратегию на ходу
}
buildRoute(from: string, to: string): string {
return this.strategy.calculateRoute(from, to);
}
}
// Использование
const navigator = new Navigator(new WalkingStrategy());
console.log(navigator.buildRoute("Дом", "Школа"));
navigator.setStrategy(new DrivingStrategy());
console.log(navigator.buildRoute("Дом", "Школа"));
Пример 7: Decorator (Декоратор)
// Decorator — добавляет новое поведение объекту, не меняя его класс
// Пример: кофейня с добавками
interface Coffee {
getCost(): number;
getDescription(): string;
}
// Базовый класс — просто кофе
class SimpleCoffee implements Coffee {
getCost(): number {
return 100; // базовая цена
}
getDescription(): string {
return "Простой кофе";
}
}
// Абстрактный декоратор — основа для всех добавок
abstract class CoffeeDecorator implements Coffee {
protected coffee: Coffee; // хранит ссылку на декорируемый объект
constructor(coffee: Coffee) {
this.coffee = coffee;
}
abstract getCost(): number;
abstract getDescription(): string;
}
// Конкретные декораторы
class MilkDecorator extends CoffeeDecorator {
getCost(): number {
return this.coffee.getCost() + 30; // добавляем цену молока
}
getDescription(): string {
return this.coffee.getDescription() + ", с молоком";
}
}
class SugarDecorator extends CoffeeDecorator {
getCost(): number {
return this.coffee.getCost() + 10;
}
getDescription(): string {
return this.coffee.getDescription() + ", с сахаром";
}
}
class WhippedCreamDecorator extends CoffeeDecorator {
getCost(): number {
return this.coffee.getCost() + 50;
}
getDescription(): string {
return this.coffee.getDescription() + ", со взбитыми сливками";
}
}
// Использование — можно комбинировать декораторы
let coffee: Coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SugarDecorator(coffee);
coffee = new WhippedCreamDecorator(coffee);
console.log(`${coffee.getDescription()} — ${coffee.getCost()} рублей`);
// "Простой кофе, с молоком, с сахаром, со взбитыми сливками — 190 рублей"
Сравнение с Python: чем похоже и чем отличается
| Аспект | TypeScript | Python |
|---|---|---|
| Типизация | Строгая, статическая (проверяется на этапе компиляции) | Динамическая (типы проверяются во время выполнения) |
| Интерфейсы | Есть на уровне языка (interface) | Нет встроенных, используются абстрактные классы (ABC) |
| Модификаторы доступа | public, private, protected | Нет ключевых слов, используется соглашение _private |
| Singleton | Через приватный конструктор + статическое поле | Через __new__ или декоратор @singleton |
| Decorator | Классический паттерн через композицию | Есть встроенные декораторы (@staticmethod, @property) |
| Наследование | Только одиночное (extends) | Множественное (class A(B, C)) |
Главное отличие: в TypeScript паттерны получаются более строгими и предсказуемыми благодаря системе типов. В Python код короче, но может содержать больше ошибок времени выполнения.
Совет: Если ты знаешь Python, обрати внимание на интерфейсы в TypeScript — это аналог абстрактных классов из модуля ABC, но с более строгими правилами.
Типичные ошибки и подводные камни
- Избыточность — не используй паттерн, если задача решается проще. Singleton для одной константы — это перебор.
- Нарушение принципа единственной ответственности — класс не должен одновременно и создавать объекты, и выполнять бизнес-логику.
- Забыли про this в TypeScript — в колбэках и методах, переданных как аргументы, контекст может потеряться. Используй стрелочные функции или bind.
- Неправильная реализация Singleton в многопоточности — в Node.js это редкость, но в браузере с Web Workers нужно быть осторожным.
- Слишком глубокое декорирование — 10 вложенных декораторов сделают код нечитаемым.
- Утечки памяти в Observer — если не отписываться от наблюдателей, объекты не будут удалены сборщиком мусора.
- Путаница между Factory и Builder — Factory создаёт объект целиком, Builder — пошагово.
Лучшие практики и советы профессионалов
- Начинай с простого — сначала напиши рабочий код без паттерна, потом рефактори. Паттерны — это инструмент, а не цель.
- Используй интерфейсы — они делают код гибким и тестируемым. Любой паттерн выигрывает от чёткого контракта.
- Документируй выбор паттерна — в комментарии напиши, почему выбран именно этот паттерн. Это поможет коллегам (и тебе через месяц).
- Не бойся комбинировать — Factory + Strategy, Builder + Decorator — это нормально.
- Тестируй изоляцию — каждый компонент паттерна должен тестироваться отдельно.
- Изучай исходники — посмотри, как паттерны реализованы в популярных библиотеках (Express, RxJS, TypeORM).
Вопросы для самопроверки
- Что такое паттерн проектирования простыми словами?
Ответ: Это готовое, проверенное решение типовой задачи в программировании. Как рецепт для приготовления блюда. - Какие три группы паттернов существуют?
Ответ: Порождающие (создание объектов), структурные (связи между объектами), поведенческие (взаимодействие объектов). - В чём разница между Factory и Builder?
Ответ: Factory создаёт объект целиком за один вызов. Builder создаёт объект пошагово, позволяя настраивать каждый шаг. - Зачем нужен Singleton и в чём его опасность?
Ответ: Singleton гарантирует единственный экземпляр класса. Опасность: усложняет тестирование и создаёт глобальное состояние. - Какой паттерн использовать, чтобы добавить новое поведение объекту без изменения его класса?
Ответ: Decorator. Он оборачивает объект и добавляет функциональность. - Что такое «подписка» в паттерне Observer?
Ответ: Это регистрация наблюдателя у издателя. Когда издатель меняет состояние, он уведомляет всех подписанных наблюдателей. - Какой паттерн позволяет менять алгоритм на ходу?
Ответ: Strategy. Контекст хранит ссылку на стратегию и может её заменить. - Почему в TypeScript паттерны надёжнее, чем в JavaScript?
Ответ: Из-за строгой типизации. TypeScript проверяет типы на этапе компиляции, а не во время выполнения. - Когда НЕ стоит использовать паттерны?
Ответ: Когда задача решается простой функцией или классом без паттерна. Паттерны добавляют сложность, и их нужно оправдывать. - Какой паттерн используется для интеграции старого кода с новым?
Ответ: Adapter. Он преобразует интерфейс одного класса в интерфейс, ожидаемый другим классом.
Итог и что изучить дальше
Паттерны проектирования — это не магия и не обязательное требование. Это инструменты, которые помогают писать чистый, понятный и гибкий код. Ты не обязан использовать их везде, но знание паттернов делает тебя профессиональным разработчиком.
Мы рассмотрели 7 основных паттернов: Singleton, Factory, Builder, Adapter, Observer, Strategy, Decorator. Этого достаточно, чтобы решать 80% типовых задач. Но есть и другие:
- Proxy — контроль доступа к объекту
- Facade — упрощённый интерфейс к сложной системе
- Command — инкапсуляция запроса в объект
- Iterator — последовательный доступ к элементам коллекции
- State — изменение поведения при смене состояния
Что изучить дальше:
- Прочитай книгу «Банды четырёх» (GoF) — классика по паттернам
- Изучи принципы SOLID — они тесно связаны с паттернами
- Попробуй реализовать паттерны в своём проекте
- Посмотри, как паттерны используются в Angular (Dependency Injection — это паттерн)
- Изучи антипаттерны — чего делать не стоит
Помни: хороший программист не тот, кто знает все паттерны, а тот, кто знает, когда их применить. Удачи в изучении!