Шаблоны безопасности типов 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:
«Для этого нужна стратегия». Прежде чем приступать к решению, спросите себя, действительно ли эти варианты представляют собой взаимозаменяемые алгоритмы в рамках одного контракта. Если да, то стратегия подойдёт. Если же проблема заключается в том, что некоторые поля имеют смысл только для определённых методов, то более подходящим инструментом может стать дискриминированный союз, не позволяющий представлять недопустимые комбинации. Знать каталог легко; настоящим навыком является соотнесение записи с конкретным сценарием использования.
Шаблоны создания
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-запросе.
Структурные шаблоны
Адаптер: стабильный контракт вокруг чужого 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
Добавление нового уровня сейчас обычно означает внедрение новой стратегии, а не изменение существующей логики, что и является практическим применением принципа «открытость/закрытость». Для трёх простых правил условие подходит; стратегический подход становится целесообразным, когда у правил появляются собственные зависимости или тесты.
Observer: уведомления от одного объекта многим подписчикам
Механизм Observer позволяет одному объекту-источнику уведомлять любое количество объектов-подписчиков о произошедших изменениях:
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-and-optionals:
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
Атрибуты-варианты вместо логических флагов
Независимые логические значения могут приводить к абсурду, например, к кнопке, которая одновременно является основной и опасной:
interface ButtonProps {
primary?: boolean;
danger?: boolean;
loading?: boolean;
}
Союз вариантов позволяет каждому из них указывать собственные требования:
type ButtonProps =
| {
variant: "primary";
loading?: boolean;
}
| {
variant: "danger";
confirmationRequired: boolean;
};
Теперь опасная версия должна указывать confirmationRequired.
API компонентов, отклоняющих противоречия
Компонент, отображающий либо ссылку, либо кнопку с необязательными атрибутами 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
Хорошие паттерны возникают по мере того, как становятся очевидными требования архитектуры; они не навязываются заранее.
Вопросы для интервью, проверяющие истинное понимание
Вопрос «Что такое паттерн Factory?» проверяет запоминание. Эти вопросы оценивают способность к суждению.
Архитектура:
- Столкнувшись с компонентом React из 2000 строк, как вы выбираете, какую абстракцию внедрить?
- Когда вы откажетесь от паттерна, который кажется подходящим?
- Как понять, что абстракция создана преждевременно?
- Когда лучше использовать композицию вместо наследования?
- Как не допустить, чтобы объект Singleton превратился в глобальное изменяемое состояние?
Основы TypeScript:
- Как вы моделируете запрос с состояниями загрузки, успеха, ошибки и повторной попытки?
- Как предотвратить недопустимые комбинации свойств React?
- В чём разница между
unknown,anyиnever? - Когда стоит выбрать
typeвместоinterfaceи наоборот? - Как
keyofвзаимодействует с генериками?
Продвинутый TypeScript:
- Какой конкретный случай использования существует у условных типов?
- Что делает
infer? - Что такое отображённый тип?
- Что такое дистрибутивные условные типы?
- Когда целесообразно использовать типы с маркировкой?
- Какую проблему решает
satisfies? - Как
as constвлияет на процесс вывода типов?
Проектирование в реальном мире:
- Создайте безопасный с точки зрения типов шину событий.
- Создайте безопасный с точки зрения типов клиент API.
- Создайте систему разрешений с использованием TypeScript.
- Создайте абстракцию платежей, поддерживающую Stripe, PayPal и ещё одного поставщика.
- Как добавить слой репозитория в существующее приложение 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и полные проверки. - Проверяйте данные на границах времени выполнения; одних только типов недостаточно для защиты в этих случаях.
- Используйте самую простую абстракцию, способную справиться с теми изменениями, которые вы действительно ожидаете.
Связанные материалы
- Управление ИИ-агентами-кодерами с помощью четырех названных шаблонов архитектуры React: Узнайте о четырех шаблонах React (секционная композиция, пользовательские хуки, полиморфные компоненты, редьюсеры) и о том, как их называние в промптах позволяет получать более чистый код, сгенерированный ИИ.