Выбор шаблонов TypeScript в зависимости от сложности проблем, которые они решают.
Обзор классических шаблонов проектирования и техник TypeScript на уровне типов, с четкими рекомендациями о том, когда каждый из них имеет смысл использовать, а когда лучше ограничиться обычным кодом.
Большинство команд используют TypeScript для автодополнения и обнаружения опечаток, а затем открывают для себя его истинную ценность: он позволяет закодировать архитектурные решения так, чтобы компилятор обеспечивал их соблюдение. В этом руководстве рассматриваются классические объектно-ориентированные паттерны и техники на уровне типов, которые имеют наибольшее значение в крупных фронтенд- и полноценных full-stack проектах, а также объясняется, как определить, стоит ли использовать тот или иной паттерн.
Рассматривайте систему типов как инструмент, способный отвечать на архитектурные вопросы:
- Возможна ли на самом деле такая комбинация значений состояния?
- Может ли эта API вернуть формат данных, которого не ожидает остальная часть кода?
- Может ли значение
ProductIdпопасть в функцию, ожидающуюUserId? - Могут ли компоненту передаваться пропсы, противоречащие друг другу?
- Если будет добавлено новое состояние, будут ли все компоненты вынуждены с ним работать?
any?Шаблон — это не функция, которую можно просто добавить. Это название решения, которое вы распознаёте, когда возникает повторяющаяся проблема.
Классификация шаблонов
Классическое деление включает шаблоны создания, структуры и поведения; TypeScript добавляет четвёртую группу техник на уровне типов, основанных на генериках, объединениях и инструментах безопасности.
TYPESCRIPT PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
CREATIONAL STRUCTURAL BEHAVIORAL
│ │ │
├─ Factory ├─ Adapter ├─ Strategy
├─ Builder ├─ Facade ├─ Observer
├─ Singleton ├─ Decorator ├─ Command
└─ Abstract Factory ├─ Repository └─ State
└─ Composition
+
TYPESCRIPT TYPE PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
Generics Unions Type Safety
│ │ │
├─ Constraints ├─ Discriminated ├─ Type Guards
├─ keyof Unions ├─ Branded Types
├─ typeof ├─ Result Types ├─ Exhaustiveness
├─ infer └─ State Modeling └─ satisfies
└─ Mapped Types
В реальных приложениях редко используется один шаблон в отдельности. Типичный поток данных в React объединяет несколько шаблонов, причём каждый участок может иметь точно определённые типы:
React Component
│
▼
Custom Hook
│
▼
Service
│
▼
Repository
│
▼
API Client
│
▼
Result<T, E>
│
▼
Discriminated Union
Когда типы проходят через всю эту цепочку, TypeScript превращается в описание того, как система может вести себя.
Начинайте с проблемы, а не со шаблона
Распространенная ошибка — рассуждения в неверном направлении:
"I know Factory Pattern.
Where can I use Factory?"
Сначала выбор шаблона приводит к созданию абстракций, которые никому не нужны. Лучше позволить самой проблеме определять выбор:
What problem do I have?
↓
Where is the complexity?
↓
What is changing frequently?
↓
What should remain stable?
↓
What abstraction reduces that complexity?
↓
Is a known pattern appropriate?
Рассмотрим процесс оплаты, в котором добавляется ряд проверок способа оплаты:
if (paymentMethod === "card") {
// ...
}
if (paymentMethod === "paypal") {
// ...
}
if (paymentMethod === "upi") {
// ...
}
Часто первой реакцией является следующее:
Don’t immediately think:
«Здесь нужен шаблон Strategy». Прежде чем его использовать, спросите себя, действительно ли эти варианты представляют собой взаимозаменяемые алгоритмы под одним контрактом. Если да, то шаблон Strategy подойдет. Если же проблема в том, что некоторые поля имеют смысл только для определенных методов, то более подходящим инструментом может стать дискриминированное соединение, не позволяющее представлять невалидные комбинации. Знать каталог элементов легко; настоящим навыком является соотнесение элемента с конкретной ситуацией.
Шаблоны создания
Singleton: один общий экземпляр
Паттерн Singleton гарантирует, что у класса будет ровно один экземпляр. Приватный конструктор блокирует создание объекта вне оператора new, а статический метод лениво создает и хранит этот экземпляр в кэше:
class Logger {
private static instance: Logger;
private constructor() {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message: string) {
console.log(message);
}
}
Пользователи запрашивают общедоступный объект вместо того, чтобы создать его сами:
const logger = Logger.getInstance();
logger.log("Application started");
Все потребители в итоге указывают на один и тот же объект:
Logger
│
getInstance()
│
▼
┌───────────┐
│ Logger │
│ Instance │
└───────────┘
▲ ▲
│ │
Service A Service B
Возможные кандидаты на использование этого паттерна включают:
- системы логирования
- механизмы аналитики
- объекты для хранения конфигурации
- некоторые менеджеры подключений
- другие инфраструктурные сервисы, используемые в разных частях приложения
Проблема в том, что паттерн Singleton на самом деле представляет собой маскированный глобальный доступ: тесты становятся сложнее для изоляции, зависимости исчезают из описаний компонентов, жизненный цикл становится неясным, а общее изменяемое состояние меняется непроизвольным образом. В современном фронтенде экземпляр, экспортируемый из ES-модуля, уже является общим, а внедрение зависимостей, React Context или библиотеки состояния обеспечивают ту же гарантию с четко прослеживаемыми связями.
Фабрика: скрытие класса, который создается
Фабрика отвлекает потребителя от принятия решения о том, какой конкретный класс необходимо инстанцировать. Сравните прямое создание:
const payment = new StripePayment();
с созданием через делегирование:
const payment = PaymentFactory.create("stripe");
Оба решения реализуют один интерфейс, а фабрика соотносит литералы строк с нужным классом. Поскольку тип параметра — "stripe" | "paypal", неподдерживаемое имя приводит к сбою компиляции:
interface PaymentProvider {
pay(amount: number): Promise<void>;
}
class StripePayment implements PaymentProvider {
async pay(amount: number) {
console.log("Stripe:", amount);
}
}
class PayPalPayment implements PaymentProvider {
async pay(amount: number) {
console.log("PayPal:", amount);
}
}
class PaymentFactory {
static create(
provider: "stripe" | "paypal"
): PaymentProvider {
switch (provider) {
case "stripe":
return new StripePayment();
case "paypal":
return new PayPalPayment();
}
}
}
Визуально фабрика представляет собой разветвление, обозначенное именем поставщика:
PaymentFactory
│
┌────────────┴────────────┐
│ │
"stripe" "paypal"
│ │
▼ ▼
StripePayment PayPalPayment
Фабрика оправдывает свое использование в следующих случаях:
- процесс создания включает реальную логику
- несколько реализаций используют один интерфейс
- потребители не должны знать конкретные классы
- реализации должны развиваться независимо от тех, кто их вызывает
Для простых операций создания, подобных той, что показана ниже, это лишь добавляет дополнительный уровень сложности:
new User();
Абстрактная фабрика: семейства связанных объектов
Абстрактная фабрика расширяет эту идею до групп объектов, которые должны соответствовать друг другу. В наборе элементов интерфейса, предназначенном для нескольких платформ, веб-кнопка никогда не должна сочетаться с мобильным модальным окном, поэтому каждая фабрика генерирует единое согласованное семейство объектов:
interface Button {
render(): void;
}
interface Modal {
open(): void;
}
interface UIFactory {
createButton(): Button;
createModal(): Modal;
}
UIFactory
│
┌────────┴────────┐
▼ ▼
WebUIFactory MobileUIFactory
│ │
┌────┴────┐ ┌────┴────┐
▼ ▼ ▼ ▼
Button Modal Button Modal
Этот подход мощный, но легко приводит к чрезмерной сложности. В большинстве фронтенд-приложений простое составление компонентов позволяет достичь желаемого результата без лишних сложностей.
Builder: контролируемое пошаговое создание
Builder полезен, когда у объекта много необязательных настроек или он конфигурируется поэтапно. Каждый метод-установщик обновляет внутренние настройки и возвращает this, что позволяет использовать цепочку вызовов:
class RequestBuilder {
private config: RequestInit = {};
setMethod(method: string) {
this.config.method = method;
return this;
}
setHeaders(headers: HeadersInit) {
this.config.headers = headers;
return this;
}
setBody(body: BodyInit) {
this.config.body = body;
return this;
}
build() {
return this.config;
}
}
Сборка запроса представляет собой последовательность четко определенных шагов:
const request = new RequestBuilder()
.setMethod("POST")
.setHeaders({
"Content-Type": "application/json"
})
.setBody(JSON.stringify(data))
.build();
Плавная синтаксис — это побочный эффект, а не цель. Главная задача — сделать сложную процедуру создания объекта явной и объединенной в одном месте, где метод build() также может выполнять проверки, например отклонять тело запроса при GET-запросе.
Структурные шаблоны
Adapter: стабильный интерфейс для API другого приложения
Адаптер является одним из наиболее часто используемых паттернов в коде приложений. Предположим, ваш код ожидает следующий внутренний контракт:
interface PaymentGateway {
pay(amount: number): Promise<void>;
}
в то время как SDK от стороннего поставщика предоставляет метод с другим именем:
class LegacyPaymentSDK {
makePayment(value: number) {
// third-party implementation
}
}
Простой адаптер реализует ваш интерфейс и преобразует вызовы:
class PaymentAdapter implements PaymentGateway {
constructor(
private readonly sdk: LegacyPaymentSDK
) {}
async pay(amount: number) {
this.sdk.makePayment(amount);
}
}
Application
│
▼
PaymentGateway
▲
│
PaymentAdapter
│
▼
Third-party SDK
Теперь приложение зависит от одного интерфейса, принадлежащего вам:
PaymentGateway
а не от каждого поставщика, стоящего за ним:
Stripe
PayPal
LegacySDK
SomeFutureProvider
Поддержка нового поставщика означает необходимость написания еще одного адаптера.
Фасад: один вызов для многоэтапного процесса
Фасад создает простую точку входа перед сложной подсистемой. Процесс входа в систему может затрагивать несколько сервисов:
Authentication
+
User Service
+
Permissions
+
Notification
+
Analytics
Без фасада каждый компонент, отвечающий за вход пользователя, сам организует последовательность действий:
auth.login();
user.load();
permission.load();
analytics.track();
Фасад выполняет эту организацию один раз:
class AppFacade {
async login(username: string, password: string) {
const token = await auth.login(username, password);
const user = await userService.getUser(token);
await permissionService.load(user);
analytics.track("login");
return user;
}
}
И компонент сводится к одному вызову:
await appFacade.login(username, password);
Component
│
▼
AppFacade
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Auth User Permission
Service Service Service
Это особенно полезно, когда важен порядок выполнения шагов.
Декоратор: добавление поведения путем обертки
Декоратор добавляет поведение без изменения исходной реализации, поскольку обертка и объект, который она оборачивает, имеют общий интерфейс. Контракт:
interface Logger {
log(message: string): void;
}
Простая реализация:
class ConsoleLogger implements Logger {
log(message: string) {
console.log(message);
}
}
Декоратор, который хранит любой объект типа Logger и добавляет в сообщения префикс с временем:
class TimestampLogger implements Logger {
constructor(
private readonly logger: Logger
) {}
log(message: string) {
this.logger.log(
`[${new Date().toISOString()}] ${message}`
);
}
}
Обертка — это просто процесс создания объекта:
const logger = new TimestampLogger(
new ConsoleLogger()
);
Поскольку каждый декоратор сам по себе является объектом типа Logger, они могут накапливаться друг на друга:
Logger
│
▼
ConsoleLogger
│
▼
TimestampLogger
│
▼
AdditionalDecorator
Та же идея реализуется под другими названиями:
- цепочки промежуточных компонентов
- функции-обертки
- компоненты высшего порядка в React
- логирование
- уровни кэширования
- проверки авторизации
Паттерны поведения
Стратегия: взаимозаменяемые алгоритмы
Стратегия устраняет разветвленные бизнес-правила. Возьмем тип уровня клиента:
type CustomerType =
| "regular"
| "premium"
| "enterprise";
Условная реализация помещает каждое правило в одну функцию:
function calculateDiscount(
type: CustomerType,
price: number
) {
if (type === "regular") {
return price;
}
if (type === "premium") {
return price * 0.9;
}
return price * 0.8;
}
С использованием стратегии каждое правило становится классом, находящимся за общим интерфейсом:
interface DiscountStrategy {
calculate(price: number): number;
}
class RegularDiscount implements DiscountStrategy {
calculate(price: number) {
return price;
}
}
class PremiumDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.9;
}
}
class EnterpriseDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.8;
}
}
Order
│
▼
DiscountStrategy
│
┌───────────┼───────────┐
▼ ▼ ▼
Regular Premium Enterprise
Strategy Strategy Strategy
Добавление нового уровня теперь обычно означает добавление новой стратегии, а не редактирование существующей логики, что и является практическим применением принципа открытости/закрытости. Для трех небольших правил условная реализация подходит; стратегия оправдывает себя, когда правила начинают иметь собственные зависимости или тесты.
Наблюдатель: уведомления от одного к многим
Механизм наблюдателя позволяет одному объекту-субъекту уведомлять любое количество объектов-слушателей при изменении ситуации:
Subject
│
┌──────────┼──────────┐
▼ ▼ ▼
Observer A Observer B Observer C
Небольшой типизированный эмиттер хранит слушателей в структуре Set и возвращает функцию для очистки при регистрации слушателя:
type Listener<T> = (value: T) => void;
class EventEmitter<T> {
private listeners = new Set<Listener<T>>();
subscribe(listener: Listener<T>) {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
emit(value: T) {
this.listeners.forEach(listener => {
listener(value);
});
}
}
const emitter = new EventEmitter<string>();
const unsubscribe = emitter.subscribe(message => {
console.log(message);
});
emitter.emit("Hello");
unsubscribe();
Эта функция для очистки отвечает за управление жизненным циклом. Если её не вызвать, это приведёт к:
- утечкам памяти из-за слушателей, которые существуют дольше своих владельцев
- дублированию обработки при двукратной привязке слушателя
- использованию устаревших значений в результате работы замыканий
- возникновению побочных эффектов после уничтожения компонента
В React именно это возвращается из обратного вызова функции useEffect.
Команда: действия в виде объектов
Команда преобразует действие в объект с единым интерфейсом:
interface Command {
execute(): void;
}
Конкретные команды реализуют этот интерфейс:
class SaveCommand implements Command {
execute() {
console.log("Saving...");
}
}
class UndoCommand implements Command {
execute() {
console.log("Undo");
}
}
Рассмотрение действий как значений полезно, когда требуется:
- история произошедших событий
- функции отмены и восстановления состояния
Для настоящего отката обычно требуется, чтобы каждая команда сама себя отменяла, поэтому в производственных версиях часто добавляется метод undo() рядом с execute().
User Action
│
▼
Command
│
├── execute()
│
▼
Receiver
Шаблоны данных и композиции
Repository: изоляция доступа к данным
Когда доступ к данным становится сложным, Repository создает контракт между бизнес-логикой и тем элементом, который хранит или загружает данные:
UI
│
▼
Hook / Controller
│
▼
Service
│
▼
Repository
│
├── REST
├── GraphQL
├── IndexedDB
└── Cache
Контракт описывает, о чем может просить приложение:
interface UserRepository {
getUser(id: string): Promise<User>;
getUsers(): Promise<User[]>;
}
Одна из реализаций взаимодействует с REST API:
class ApiUserRepository implements UserRepository {
async getUser(id: string) {
const response = await fetch(`/users/${id}`);
return response.json();
}
async getUsers() {
const response = await fetch("/users");
return response.json();
}
}
Бизнес-код зависит от интерфейса:
UserRepository
а не от способа передачи данных:
fetch()
axios()
graphqlClient()
Это важно, когда меняется исходный код: переход с REST на GraphQL, добавление кэша IndexedDB или использование временных данных в памяти для тестирования. Обратите внимание, что response.json() возвращает значение без указания типа, поэтому репозиторий также является подходящим местом для проверки ответов.
Композиция вместо наследования
В работе с фронтендом композиция важнее любых паттернов, основанных на наследовании. Вместо одного компонента, который контролирует всё:
MegaComponent
├── Authentication
├── Table
├── Filters
├── Modal
├── Notifications
├── API calls
└── Business logic
разделяйте ответственности на более узкие части:
Dashboard
├── Header
├── Sidebar
├── FilterPanel
├── DataTable
└── NotificationPanel
В React это достигается простым вложением компонентов:
<Dashboard>
<Header />
<Sidebar />
<MainContent />
</Dashboard>
Каждая часть может быть понята, протестирована и заменена отдельно. Подробнее о разбиении крупных компонентов см. как исправить перегрузку свойств в React с помощью композиции и слотов.
Моделирование состояния с помощью системы типов
Начиная с этого момента, сам TypeScript является архитектурным инструментом.
Дискриминированные союзы для состояния запроса
Запрос проходит через этапы, на которых обрабатываются разные данные. Дискриминированный союз предоставляет каждому этапу свою собственную структуру, связанную общим полем status:
type RequestState =
| {
status: "idle";
}
| {
status: "loading";
}
| {
status: "success";
data: User[];
}
| {
status: "error";
error: string;
};
Использование дискриминанта позволяет узко определить тип в каждом ветке:
function render(state: RequestState) {
switch (state.status) {
case "idle":
return "Nothing started";
case "loading":
return "Loading...";
case "success":
return state.data;
case "error":
return state.error;
}
}
Компилятор отслеживает, какие поля существуют после каждой проверки:
status = "success"
↓
data exists
status = "error"
↓
error exists
Сравните с распространенной моделью boolean-и-опциональных значений:
interface State {
loading: boolean;
data?: User[];
error?: string;
}
Ничто не мешает описывать состояния, которые никогда не должны возникнуть:
{
loading: true,
data: [...],
error: "Something failed"
}
Союз затрудняет выражение подобных комбинаций. Лучше моделировать допустимые состояния, вместо того чтобы разбрасывать опциональные свойства и полагаться на то, что все правильно их объединят.
Типы результата для явных ошибок
У многих операций есть ровно два возможных исхода:
Success
OR
Failure
Тип Result описывает оба исхода с использованием логического параметра:
type Result<T, E> =
| {
success: true;
data: T;
}
| {
success: false;
error: E;
};
Функция, возвращающая этот тип:
function getUser(): Result<User, string> {
return {
success: true,
data: user
};
}
Вызывающий код должен проверить значение success перед обращением к полям data или error:
const result = getUser();
if (result.success) {
console.log(result.data);
} else {
console.error(result.error);
}
Service
│
▼
Result<T, E>
/ \
/ \
▼ ▼
Success Failure
│ │
data error
Это подходит для ожидаемых сбоев в бизнес-логике, таких как «электронная почта уже зарегистрирована». Исключения же предназначены для настоящих ошибок и сбоев инфраструктуры; тип Result просто делает ожидаемые сбои видимыми в сигнатуре функции.
Генерики и инструменты на уровне типов
Генерики сохраняют связь между входными и выходными данными
Использование типа any приводит к потере информации:
function identity(value: any): any {
return value;
}
Параметр типа сохраняет эту информацию:
function identity<T>(value: T): T {
return value;
}
Выводимый результат соответствует аргументам функции:
const a = identity("hello");
// string
const b = identity(100);
// number
Связь между входными и выходными данными сохраняется:
Input T
│
▼
Function<T>
│
▼
Output T
Генерики также позволяют повторно использовать общие контракты. Одна оболочка ответа:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
описывает множество полезных нагрузок:
type UserResponse =
ApiResponse<User>;
type ProductResponse =
ApiResponse<Product>;
Ограничения: требование к форме
Это не компилируется:
function getId<T>(item: T) {
return item.id;
}
потому что TypeScript не знает, что у T есть поле id. Ограничение добавляет такую гарантию:
function getId<T extends { id: string }>(
item: T
) {
return item.id;
}
Принимается любой объект с строковым полем id, включая дополнительные поля:
getId({
id: "123",
name: "Hareesh"
});
Читайте ограничение следующим образом:
T can be anything
BUT
T must have id: string
keyof и индексированный доступ
Учитывая интерфейс:
interface User {
id: string;
name: string;
age: number;
}
keyof возвращает объединение имен его свойств:
type UserKey = keyof User;
"id" | "name" | "age"
Сочетание keyof с вторым параметром типа и типом индексированного доступа T[K] приводит к созданию оператора доступа, тип возвращаемого значения которого совпадает с ключом:
function getProperty<T, K extends keyof T>(
object: T,
key: K
): T[K] {
return object[key];
}
const user = {
id: "1",
name: "Hareesh",
age: 30
};
getProperty(user, "name");
Реальный ключ компилируется:
getProperty(user, "name");
Отсутствующий ключ вызывает ошибку компиляции:
getProperty(user, "salary");
Преимущество заключается в совместной работе трех инструментов:
Generics
+
keyof
+
Indexed Access
Типы-карты: преобразование существующего типа
Типы-карты перебирают ключи типа для создания нового типа. Начиная с:
interface User {
id: string;
name: string;
email: string;
}
можно получить версию с полностью опциональными полями:
type OptionalUser = {
[K in keyof User]?: User[K];
};
концептуально эквивалентно написанию:
{
id?: string;
name?: string;
email?: string;
}
Так определяются встроенные конструкции, такие как Partial.
Условные типы: принятие решений на уровне типа
Условные типы выбирают между двумя типами на основе возможности присвоения:
T extends U ? X : Y
Этот инструмент разбирает типы элементов массива и оставляет всё остальное без изменений:
type Flatten<T> =
T extends Array<infer U>
? U
: T;
type A = Flatten<string[]>;
// string
type B = Flatten<number>;
// number
На этом этапе система типов ведёт себя как небольшой язык на этапе компиляции, что требует осторожности.
infer: извлечение части типа
Внутри условного типа infer объявляет переменную типа, которую TypeScript заполняет путём сопоставления. Это переосуществляет встроенный ReturnType:
type MyReturnType<T> =
T extends (...args: any[]) => infer R
? R
: never;
Используйте его вместе с typeof, чтобы получить тип из существующей функции, тем самым предотвращая отклонения от её реализации:
function getUser() {
return {
id: "1",
name: "Hareesh"
};
}
type User = MyReturnType<typeof getUser>;
Определения типов в библиотеках в значительной степени зависят от этого.
Сначала встроенные вспомогательные типы
Прежде чем писать сложные вспомогательные функции, узнайте, что уже входит в состав языка:
Partial
Required
Readonly
Pick
Omit
Record
Exclude
Extract
NonNullable
ReturnType
Parameters
InstanceType
Awaited
Удаление чувствительного поля осуществляется за одну строку, вместо использования дублированного интерфейса, из-за чего может наступить разногласие:
interface User {
id: string;
name: string;
email: string;
password: string;
}
type PublicUser =
Omit<User, "password">;
Руководство по встроенным в TypeScript утилитным типам подробно рассматривает каждый из этих случаев.
Record с замкнутым набором ключей
Record подходит для поиска и настройки, особенно когда ключи берутся из литерального объединения:
type Permission =
"read" |
"write" |
"delete";
type PermissionMap =
Record<Permission, boolean>;
const permissions: PermissionMap = {
read: true,
write: false,
delete: false
};
Если пропустить разрешение, TypeScript сообщит об отсутствующем ключе. Широкий тип ключа лишает нас такой гарантии:
const permissions: Record<string, boolean>
поскольку Record<string, boolean> принимает практически любой строковый ключ, поэтому пропуски и опечатки остаются незамеченными.
Шаблоны безопасности для идентификаторов и ненадежных данных
Типы с маркировкой для идентификаторов домена
Два идентификатора могут быть строками, но означать разные вещи:
const userId: string;
const productId: string;
С точки зрения структуры TypeScript не может отличить их друг от друга. Сложение типа string с фиктивным свойством создает разные типы:
type UserId =
string & {
readonly __brand: "UserId";
};
type ProductId =
string & {
readonly __brand: "ProductId";
};
Затем функции могут требовать наличия именно определенного вида идентификатора:
function getUser(id: UserId) {}
function getProduct(id: ProductId) {}
Передача ProductId там, где ожидается UserId, приводит к ошибке компиляции. Этот бренд никогда не существует во время выполнения; значения, связанные с брендом, создаются с помощью небольшого конструктора или функции валидации, которые выполняют приведение типов в одном месте. Концептуально:
string
│
├── UserId
├── ProductId
├── OrderId
└── TransactionId
Это оправдано в крупных системах, где десятки идентификаторов используют один примитивный тип.
Защитники типов для входных данных типа unknown
Данные из сети, хранилища или ввода пользователя должны поступать в виде unknown:
const data: unknown = await response.json();
Приведение типов — это соблазнительный ускоритель:
const user = data as User;
Вместо этого используйте пользовательский тип-защитник для ограничения значения; его тип возврата value is User сообщает компилятору, что подтверждает результат true:
function isUser(
value: unknown
): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}
if (isUser(data)) {
console.log(data.name);
}
Помните, что типы уничтожаются во время выполнения. Интерфейс вроде этого:
interface User {
id: string;
}
не проверяет ничего из того, что отправляет API. Приведенный выше защитник проверяет лишь наличие ключей, а не их типы; поэтому для ненадежных входных данных используйте TypeScript с валидатором схемы во время выполнения.
Полная проверка с помощью never
Одно из самых эффективных сочетаний в языке:
Discriminated Union
+
never
+
switch
Используйте союз статусов:
type Status =
| "loading"
| "success"
| "error";
и вспомогательную функцию, принимающую только never:
function assertNever(
value: never
): never {
throw new Error(
`Unexpected value: ${value}`
);
}
Когда обрабатываются все элементы, фигура по умолчанию видит тип never, поэтому вызов компилируется:
function render(status: Status) {
switch (status) {
case "loading":
return "Loading";
case "success":
return "Success";
case "error":
return "Error";
default:
return assertNever(status);
}
}
Теперь коллега расширяет объединение:
Now imagine someone adds:"cancelled" to Status.
По умолчанию используется значение "cancelled", которое нельзя присвоить значению never, в результате чего сборка завершается с ошибкой до тех пор, пока этот случай не будет обработан. Компилятор становится проверщиком проекта, запоминающим каждого потребителя.
Типы литералов шаблонов
TypeScript позволяет создавать типы строковых литералов на основе других типов:
type Entity =
"user" |
"order" |
"product";
type Event =
`${Entity}:created` |
`${Entity}:updated` |
`${Entity}:deleted`;
Результатом становится объединение, включающее все возможные комбинации:
user:created
user:updated
user:deleted
order:created
order:updated
order:deleted
product:created
product:updated
product:deleted
Практические применения включают:
- названия событий
- ключи аналитических событий
- строки с правами доступа
- шаблоны маршрутов
- названия флагов функций
- токены системы дизайна
as const при использовании значений в качестве источника правды
Литерал массива строк расширяется:
const roles = [
"admin",
"editor",
"viewer"
];
Его тип — string[]. Добавление as const приводит к созданию тупла из литералов с режимом чтения только:
const roles = [
"admin",
"editor",
"viewer"
] as const;
Из него напрямую следует тип объединения:
type Role =
typeof roles[number];
becomes:
"admin" |
"editor" |
"viewer"
Определите значения один раз и никогда не ведите ручное управление параллельным объединением.
satisfies для проверки конфигурации
satisfies проверяет выражение по отношению к типу, не преобразуя его в этот тип:
type Config = {
retries: number;
environment:
| "development"
| "production";
};
const config = {
retries: 3,
environment: "production"
} satisfies Config;
Объект проверяется по отношению к Config, однако config.environment сохраняет литеральный тип "production", который обычная аннотация потеряла бы. Это подходит для:
- конфигурации маршрутов
- флагов функций
- дизайн-токенов
- объектов статических настроек
- карт разрешений
Внедрение зависимостей
Создание зависимостей внутри класса связывает его с ними:
class UserService {
private api = new ApiClient();
}
Получение их через конструктор сохраняет класс агностичным:
class UserService {
constructor(
private readonly api: ApiClient
) {}
}
В производственной среде передаётся настоящий клиент:
const service =
new UserService(apiClient);
а в тестах — имитация:
const service =
new UserService(mockApiClient);
UserService
▲
│
Dependency
│
┌────────┴────────┐
▼ ▼
ApiClient MockApiClient
Production Testing
Это один из самых простых способов создания тестируемого кода, и для этого достаточно параметров конструктора.
Различие похожих паттернов
Паттерн состояний или дискриминированное соединение?
Учитывая жизненный цикл заказа:
Draft
Paid
Shipped
Cancelled
Возможно, достаточно дискриминированного соединения:
type Order =
| { status: "draft" }
| { status: "paid" }
| { status: "shipped" }
| { status: "cancelled" };
Но когда каждое состояние имеет значительное поведение:
Draft
├── edit()
├── submit()
Paid
├── refund()
├── ship()
Shipped
├── track()
└── deliver()
более подходящим может оказаться паттерн состояний, при котором каждый объект состояния реализует разрешённые операции. Решайте, исходя из уровня сложности каждого состояния, а не по терминологии.
Стратегия или состояния?
Частая тема интервью. С помощью стратегии клиент выбирает алгоритм:
Order
│
▼
Strategy
├── CreditCard
├── PayPal
└── UPI
С помощью состояния объект меняет своё поведение по мере прохождения жизненного цикла:
Order
│
├── Draft
├── Paid
└── Shipped
Стратегия влияет на способ выполнения задачи; состояние определяет действия объекта на текущем этапе.
Фабрика или стратегия?
Фабрика отвечает на вопрос «какой объект следует создать?»:
PaymentFactory.create("stripe");
Стратегия отвечает на вопрос «какое поведение следует применить?»:
new Order(discountStrategy);
Они естественным образом сочетаются: фабрика создаёт стратегию, выполняющую работу:
Factory
↓
creates
↓
Strategy
↓
executes behavior
Применение шаблонов в React
Атрибуты variant вместо логических флагов
Независимые логические значения могут приводить к абсурду, например к кнопке, которая одновременно является основной и опасной:
interface ButtonProps {
primary?: boolean;
danger?: boolean;
loading?: boolean;
}
Союз вариантов позволяет каждому из них задавать собственные требования:
type ButtonProps =
| {
variant: "primary";
loading?: boolean;
}
| {
variant: "danger";
confirmationRequired: boolean;
};
Теперь вариант опасности должен указывать параметр confirmationRequired.
Интерфейсы компонентов, отклоняющие противоречия
Компонент, отображающий либо ссылку, либо кнопку с необязательными параметрами href и onClick, мог бы разрешать наличие обоих или ни одного из них. Приведённый ниже фрагмент демонстрирует нестрогую версию, вариант с различением параметров и корректное использование:
{
href?: string;
onClick?: () => void;
}
use:
type ActionProps =
| {
type: "link";
href: string;
}
| {
type: "button";
onClick: () => void;
};
Now:
<Action
type="link"
href="/users"
/>
Вариант ссылки, у которого указан обработчик клика вместо параметра href, отклоняется:
<Action
type="link"
onClick={...}
/>
Поддерживаемые режимы, сформулированные просто:
Link
Button
Теперь TypeScript является частью архитектуры компонентов, а не устаревающей документацией.
Безопасный с точки зрения типов шину событий
Начните с словаря, связывающего имена событий с типами данных:
type Events = {
"user:created": User;
"user:deleted": UserId;
"order:created": Order;
};
Генерик шины событий, основанный на этом словаре, связывает каждое имя с соответствующими данными с помощью keyof и T[K]:
class EventBus<T extends Record<string, unknown>> {
on<K extends keyof T>(
event: K,
handler: (data: T[K]) => void
) {
// implementation
}
emit<K extends keyof T>(
event: K,
data: T[K]
) {
// implementation
}
}
Правильный пейлоад компилируется:
bus.emit("user:created", user);
Неправильный — нет:
bus.emit("user:created", order);
Здесь применяется несколько инструментов:
Generics
+
keyof
+
Indexed Access
+
Mapped Type thinking
Типы во всем слое API
Зрелый фронтенд часто организует доступ к данным следующим образом:
Component
↓
Hook
↓
Service
↓
Repository
↓
API Client
↓
HTTP
при этом типы передаются на каждом шаге. Репозиторий может принимать идентификаторы с брендингом и возвращать объект Result:
type ApiResponse<T> = {
data: T;
status: number;
};
interface UserRepository {
getUser(id: UserId):
Promise<Result<User, ApiError>>;
}
Компонент больше не обрабатывает это на каждом уровне:
any
Сами сигнатуры указывают, что может пройти успешно, что может сорваться и с какими типами.
Антипаттерны, которых следует избегать
any везде
function process(data: any) {}
Параметр any отключает проверку всего, с чем он взаимодействует. Лучше использовать:
function process(data: unknown) {}
и сужать типы перед использованием.
Ассертции в качестве проверки
const user =
response.data as User;
Преобразование с помощью as ничего не проверяет; оно лишь просит компилятор поверить вам. Проверяйте ненадежные данные во время выполнения.
Генерики ради самих себя
Избегайте таких форматов:
function transform<
T,
U,
V,
R
>(...) {}
если каждый параметр типа не отражает реальную связь. Параметр типа, используемый один раз, обычно не нужен.
Союз типов с сотней элементов труден в использовании и развитии. Возможно, потребуется другая абстракция, такая как вложенные союзы или перемещение вариаций в данные.
Хитрости на уровне типов
Когда тип становится сложнее для понимания, чем описываемая им бизнес-логика, отступите на шаг назад:
Type complexity
│
▼
Developer complexity
│
▼
Maintenance cost
Безопасность типов имеет свою цену. Стремитесь к наиболее полезной безопасности за единицу сложности, а не к самым сложным типам.
Дерево принятия решений для выбора
Эта схема соотносит распространённые задачи с соответствующими шаблонами решений:
PROBLEM
│
├── Need to create objects?
│ ├── Simple creation → Constructor
│ ├── Complex creation → Builder
│ ├── Multiple implementations → Factory
│ └── Families of objects → Abstract Factory
│
├── Need to integrate another system?
│ └── Adapter
│
├── Complex subsystem?
│ └── Facade
│
├── Add behavior without modifying object?
│ └── Decorator
│
├── Multiple interchangeable algorithms?
│ └── Strategy
│
├── Subscribers react to changes?
│ └── Observer
│
├── Need actions/history/undo?
│ └── Command
│
├── Data-access abstraction?
│ └── Repository
│
├── Complex state lifecycle?
│ └── State / Discriminated Union
│
└── Type-level problem?
├── Reuse → Generics
├── Transform → Mapped Types
├── Decision → Conditional Types
├── Extract → infer
├── Property safety → keyof
├── Literal safety → as const
├── Contract validation → satisfies
└── Domain safety → Branded Types
Рассматривайте её как отправную точку для обсуждения; принцип «начинать с простого» остаётся актуальным на каждом уровне.
Что изучать в первую очередь
Для инженеров фронтенда, готовящихся к должностям старшего уровня или руководителей, запоминание всех шаблонов из подхода Gang of Four — плохое использование времени. Рекомендуемый порядок изучения:
Основные шаблоны:
Discriminated Unions
Generics
Type Guards
keyof
Mapped Types
Utility Types
Composition
Strategy
Repository
Factory
Крайне рекомендуемое следующее:
Result Type
Branded Types
Conditional Types
infer
Exhaustive Checking
Dependency Injection
Adapter
Facade
Observer
Decorator
Целесообразно понимать концептуально:
Builder
Command
State
Abstract Factory
Singleton
В повседневной работе над фронтендом гораздо чаще используется именно эта комбинация шаблонов:
Generics
+
Discriminated Unions
+
Composition
+
Strategy
+
Repository
чем любой шаблон абстрактной фабрики из учебников.
Как меняется подход с опытом
В начале карьеры инженеры спрашивают: «Какой шаблон мне использовать здесь?» Набрав опыт работы с крупными системами, вопрос меняется на: «Какая минимальная степень абстракции позволит решить проблему, не усложнив при этом понимание системы?» Порядок работы, основанный на шаблонах, выглядит следующим образом:
Problem
↓
Pattern
↓
More classes
↓
More abstractions
Порядок работы, основанный на проблеме, выглядит следующим образом:
Problem
↓
Understand volatility
↓
Identify boundary
↓
Start simple
↓
Introduce abstraction only where repetition/change justifies it
Хорошие шаблоны появляются тогда, когда становятся ясны требования архитектуры; их не навязывают заранее.
Вопросы на собеседовании, проверяющие реальное понимание
Вопрос «Что такое шаблон фабрики?» проверяет запоминание. Эти вопросы оценивают способность к принятию решений.
Архитектура:
- Столкнувшись с компонентом React из 2000 строк, как вы выберете степень абстракции, которую следует внедрить?
- Когда вы откажетесь от шаблона, который кажется подходящим?
- Как понять, что степень абстракции применяется преждевременно?
Основы TypeScript:
- Как моделировать запрос с состояниями загрузки, успеха, ошибки и повторной попытки?
- Как предотвратить недопустимые комбинации свойств React?
- В чём разница между
unknown,anyиnever? - Когда стоит выбрать
typeвместоinterface, и наоборот? - Как
keyofвзаимодействует с генериками?
Расширенные возможности TypeScript:
- Какой конкретный случай использования существует для условных типов?
- Что делает функция
infer? - Что такое отображённый тип?
- Что такое дистрибутивные условные типы?
- Когда целесообразно использование брендированных типов?
- Какую проблему решает функция
satisfies? - Как
as constвлияет на процесс вывода заключений?
Практическое проектирование:
- Создать безопасный с точки зрения типов шину событий.
- Создать безопасный с точки зрения типов клиент API.
- Создать систему разрешений с использованием TypeScript.
- Создать абстракцию платежей, поддерживающую Stripe, PayPal и ещё одного поставщика.
- Как добавить слой Repository в существующее приложение React без полной переработки?
- Как задать типы событий WebSocket с разными наборами данных?
- Как предотвратить передачу
ProductIdтам, где требуетсяUserId?
Откровенный вопрос: «Опишите момент, когда вы намеренно решили не использовать шаблон проектирования». Хороший ответ объясняет компромисс: при наличии единственной реализации и отсутствии вероятных изменений, которые требовали бы защиты, косвенное управление не снизило бы сложность, поэтому код оставался простым до тех пор, пока второй случай использования не стал этого оправданием.
Заключительная модель мышления
Начните с бизнес-проблемы, определите, где заключается сложность, отделите изменения в поведении от изменений в структуре, а затем позвольте типобезопасности усилить результат.
BUSINESS PROBLEM
│
▼
Identify Complexity
│
▼
What is likely to change?
│
┌──────────┴──────────┐
▼ ▼
Behavior Structure
│ │
Strategy/State Adapter/Facade
│ │
└──────────┬──────────┘
▼
Type Safety
│
┌───────────────┼────────────────┐
▼ ▼ ▼
Generics Unions Utilities
│ │ │
▼ ▼ ▼
keyof Result Type Mapped Types
infer State Model Conditional
satisfies Exhaustive Record
│ │ │
└───────────────┼────────────────┘
▼
SIMPLEER CODE
Основные выводы
Шаблоны наилучшим образом функционируют как общий словарный запас: «это адаптер», «эти поведения взаимозаменяемы», «эти состояния принадлежат к союзу», «маркируйте эти идентификаторы» и, что особенно важно, «эта абстракция ещё не заслужила своей сложности». Качество TypeScript оценивается по наличию у системы этих свойств, а не по изощрённости её типов:
Invalid states
↓
become difficult to represent
Changing implementations
↓
don't break consumers
Business rules
↓
are visible in the types
Shared behavior
↓
is reusable without duplication
Complexity
↓
is isolated behind clear boundaries
- Выбирайте шаблоны в зависимости от сложности, которую они устраняют, а не от степени знакомства с ними.
- Вместо фиксированных полей и надежд предпочитайте союзы, типы
Resultи полные проверки. - Проверяйте данные на границах времени выполнения; одних только типов недостаточно для защиты в таких случаях.
- Используйте самую простую абстракцию, способную обработать те изменения, которые вы действительно ожидаете.