Выбір шаблонаў TypeScript за ўзмоцнасцю, якую яны усуваюць
Экскурсія па класычных шаблонах дизайну і тэхніках TypeScript на рэвэле типаў, з чыстым кераваннем пра тое, калі кожны з іх ўзьме сваё месца, а калі просты код будзе кращы.
Большасць команд выбірае TypeScript для автодапамогі та выявлення падчынства, а потым адкрывае яго справжню цэннасць: ён дазволяе кодаваць рашэнні па дизайну так, каб компілятор іх прыменяў. У гэтым кансалтате рассказваецца пра класычныя об’ектно-оріѐнтаваныя шаблоны та тэхнікі на рывень типаў, якія маюць важлівое значэнне ў большых фронтенд- та фул-стэк-кодбазах, а таксама показваецца, як выбраць, калі шаблон вартаў сваёй цены.
Спачатку спробуйце уявіць систему типаў як адносна тое, што можа адпаведзець на архітэктурныя запытанні:
- Чы рэальна такая комбінацыя значэнняй стану?
- Чы гэты 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
Дадзенне новага рангу зараз зазвычай значыць дадзенне новай стратэгіі, а не рэдагаванне існуючай логіки, што ў практыцы являе сабою прынцып адкрытасі/закрытасі. Для трох маленькіх правілаў умовная рэалізацыя падходзіць; стратэгія становіцца корыстной, калі правіла пачынаюць мятчыцца залежнасцямі чыста тэстамі.
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 і redo
Справжніе дзеяння «выканаць зворачнае дзеянне» зазвычай выклікаюць неабходнасць перадзворачваць кожную команду, таму версіі для практычнага викорыстання часта дадзуюць метод 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-і-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
чым будь-якім шаблонам Abstract Factory з падручнікаў.
Як змянюецца суджэнне з адным дазнаўанням
Спачатку інжынеры спытваюць: «Калькатуру калі лучыць вжыць тут?» Заўсёды маючы апыт роботы з вялікімі системамі, пытанне ператвараецца на: «Якая ўсунення є самай маленькай, якая рашае гэту проблему без таго, каб сістэма стала важкай для розумення?» Процес работы, калі спачатку выбираецца калькатаура, выглядае так:
Problem
↓
Pattern
↓
More classes
↓
More abstractions
Процес работы, калі спачатку аналізуецца проблема, выглядае так:
Problem
↓
Understand volatility
↓
Identify boundary
↓
Start simple
↓
Introduce abstraction only where repetition/change justifies it
Хорашыя калькатуры з’яўляюцца, калі стаюць ясныя трыбы архітэктуры; іх не нав’язваюць заздалегідь.
Запытанні пад час адбору, якія пераканаюць у справжнім розумеўцы
«Што такое калькатаура Factory?» пераканаець у запам’ятованні. Гэтыя запытанні пераканаюць у спроможнасці да суджэння.
Архітэктура:
- Калі стоіць фрагмент компонента React з 2000 рэйсаў, як вы выбіраеце, якая усунення трэба аднавіць?
- Калі вы моглі б адмовіцца ад калькатуры, якая здаецца падходзячай?
- Як можна з’ясавіць, што усунення є прытэмным?
Асновы TypeScript:
- Як бы вы моделіравалі запит з станамі загрузкі, успеху, адказа пра памылку і перапрыбутку?
- Як запобiec некоректным комбінацыям пропаў 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і повнавыя перакананні, чым неабязковыя поля і надзею. - Паўнерачвайце даныя на межах часовага выканання; толькі типы там вас не захаваюць.
- Шукайце самую простую абстракцыю, якая справляецца з тымі зменамі, якія вы насправдзе чакаеце.