📘 TypeScript

Паттерны проектирования в TypeScript: подробное руководство

Что это такое и зачем? (аналогия из реальной жизни)

Представь, что ты строишь дом. Ты можешь каждый раз изобретать велосипед: придумывать, как соединять брёвна, как делать крышу, как проводить электричество. А можешь взять готовые чертежи и решения, которые уже проверили тысячи строителей до тебя. Паттерны проектирования — это и есть такие готовые «чертежи» для программистов.

В реальной жизни мы постоянно используем шаблоны. Например, когда ты заказываешь пиццу, ты не идёшь на кухню и не месишь тесто сам — ты просто говоришь оператору: «Маргариту, пожалуйста». Это и есть паттерн «Фабрика»: ты не знаешь, как именно готовится пицца, но получаешь готовый результат.

Паттерны проектирования — это проверенные способы решения типовых задач в программировании. Они не привязаны к конкретному языку, но в 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, но с более строгими правилами.

Типичные ошибки и подводные камни

  1. Избыточность — не используй паттерн, если задача решается проще. Singleton для одной константы — это перебор.
  2. Нарушение принципа единственной ответственности — класс не должен одновременно и создавать объекты, и выполнять бизнес-логику.
  3. Забыли про this в TypeScript — в колбэках и методах, переданных как аргументы, контекст может потеряться. Используй стрелочные функции или bind.
  4. Неправильная реализация Singleton в многопоточности — в Node.js это редкость, но в браузере с Web Workers нужно быть осторожным.
  5. Слишком глубокое декорирование — 10 вложенных декораторов сделают код нечитаемым.
  6. Утечки памяти в Observer — если не отписываться от наблюдателей, объекты не будут удалены сборщиком мусора.
  7. Путаница между Factory и Builder — Factory создаёт объект целиком, Builder — пошагово.

Лучшие практики и советы профессионалов

  • Начинай с простого — сначала напиши рабочий код без паттерна, потом рефактори. Паттерны — это инструмент, а не цель.
  • Используй интерфейсы — они делают код гибким и тестируемым. Любой паттерн выигрывает от чёткого контракта.
  • Документируй выбор паттерна — в комментарии напиши, почему выбран именно этот паттерн. Это поможет коллегам (и тебе через месяц).
  • Не бойся комбинировать — Factory + Strategy, Builder + Decorator — это нормально.
  • Тестируй изоляцию — каждый компонент паттерна должен тестироваться отдельно.
  • Изучай исходники — посмотри, как паттерны реализованы в популярных библиотеках (Express, RxJS, TypeORM).

Вопросы для самопроверки

  1. Что такое паттерн проектирования простыми словами?
    Ответ: Это готовое, проверенное решение типовой задачи в программировании. Как рецепт для приготовления блюда.
  2. Какие три группы паттернов существуют?
    Ответ: Порождающие (создание объектов), структурные (связи между объектами), поведенческие (взаимодействие объектов).
  3. В чём разница между Factory и Builder?
    Ответ: Factory создаёт объект целиком за один вызов. Builder создаёт объект пошагово, позволяя настраивать каждый шаг.
  4. Зачем нужен Singleton и в чём его опасность?
    Ответ: Singleton гарантирует единственный экземпляр класса. Опасность: усложняет тестирование и создаёт глобальное состояние.
  5. Какой паттерн использовать, чтобы добавить новое поведение объекту без изменения его класса?
    Ответ: Decorator. Он оборачивает объект и добавляет функциональность.
  6. Что такое «подписка» в паттерне Observer?
    Ответ: Это регистрация наблюдателя у издателя. Когда издатель меняет состояние, он уведомляет всех подписанных наблюдателей.
  7. Какой паттерн позволяет менять алгоритм на ходу?
    Ответ: Strategy. Контекст хранит ссылку на стратегию и может её заменить.
  8. Почему в TypeScript паттерны надёжнее, чем в JavaScript?
    Ответ: Из-за строгой типизации. TypeScript проверяет типы на этапе компиляции, а не во время выполнения.
  9. Когда НЕ стоит использовать паттерны?
    Ответ: Когда задача решается простой функцией или классом без паттерна. Паттерны добавляют сложность, и их нужно оправдывать.
  10. Какой паттерн используется для интеграции старого кода с новым?
    Ответ: Adapter. Он преобразует интерфейс одного класса в интерфейс, ожидаемый другим классом.

Итог и что изучить дальше

Паттерны проектирования — это не магия и не обязательное требование. Это инструменты, которые помогают писать чистый, понятный и гибкий код. Ты не обязан использовать их везде, но знание паттернов делает тебя профессиональным разработчиком.

Мы рассмотрели 7 основных паттернов: Singleton, Factory, Builder, Adapter, Observer, Strategy, Decorator. Этого достаточно, чтобы решать 80% типовых задач. Но есть и другие:

  • Proxy — контроль доступа к объекту
  • Facade — упрощённый интерфейс к сложной системе
  • Command — инкапсуляция запроса в объект
  • Iterator — последовательный доступ к элементам коллекции
  • State — изменение поведения при смене состояния

Что изучить дальше:

  1. Прочитай книгу «Банды четырёх» (GoF) — классика по паттернам
  2. Изучи принципы SOLID — они тесно связаны с паттернами
  3. Попробуй реализовать паттерны в своём проекте
  4. Посмотри, как паттерны используются в Angular (Dependency Injection — это паттерн)
  5. Изучи антипаттерны — чего делать не стоит
Помни: хороший программист не тот, кто знает все паттерны, а тот, кто знает, когда их применить. Удачи в изучении!