Пошук уроків, статей та іншого контенту
Моделюємо варіанти станів через дискриміновані об’єднання та безпечно обробляємо кожен випадок.
Дискриміноване об’єднання — це об’єднання типів, у якому кожен варіант має спільне поле-дискримінатор. Значення цього поля відрізняє один варіант від іншого.
Для стану завантаження даних таким полем часто є status:
type RequestState =
| { status: "idle" }
| { status: "loading"; startedAt: number }
| { status: "success"; data: string[] }
| { status: "error"; message: string; retryable: boolean };У RequestState є чотири можливі форми:
у стані "idle" немає додаткових даних;
у стані "loading" доступний час початку завантаження;
у стані "success" доступний масив даних;
у стані "error" доступні повідомлення та ознака можливості повторної спроби.
TypeScript не дозволить звернутися до поля, якого немає в поточному варіанті:
function getItems(state: RequestState): string[] {
if (state.status === "success") {
return state.data;
}
return [];
}Після перевірки state.status === "success" TypeScript звужує тип state до:
{ status: "success"; data: string[] }Тому звернення до state.data стає безпечним.
Той самий стан можна описати менш точно:
type UnsafeRequestState = {
loading?: boolean;
data?: string[];
error?: string;
};Такий тип допускає суперечливі значення:
const invalidState: UnsafeRequestState = {
loading: true,
data: ["готові дані"],
error: "Одночасно сталася помилка",
};З типу незрозуміло:
чи може стан бути одночасно успішним і помилковим;
коли поле data гарантовано існує;
чи пов’язане поле error із певним статусом;
які комбінації властивостей є припустимими.
Дискриміноване об’єднання описує лише дозволені комбінації:
type SafeRequestState =
| { status: "idle" }
| { status: "loading"; startedAt: number }
| { status: "success"; data: string[] }
| { status: "error"; message: string; retryable: boolean };Тепер неможливо створити успішний стан без data:
const state: SafeRequestState = {
status: "success",
data: ["TypeScript"],
};А наступний код спричинить помилку типізації:
const invalidState: SafeRequestState = {
status: "success",
// Помилка: для цього варіанта обов'язкове поле data
};Найчастіше для звуження використовують if або switch.
function describeState(state: RequestState): string {
switch (state.status) {
case "idle":
return "Запит ще не розпочато";
case "loading":
return `Запит розпочато о ${state.startedAt}`;
case "success":
return `Отримано елементів: ${state.data.length}`;
case "error":
return state.retryable
? `Помилка: ${state.message}. Можна повторити запит`
: `Критична помилка: ${state.message}`;
}
}У кожній гілці TypeScript знає точну форму state. Наприклад:
у case "loading" доступне state.startedAt;
у case "success" доступне state.data;
у case "error" доступні state.message і state.retryable.
Звернення до поля іншого варіанта буде помилкою:
function invalidDescription(state: RequestState): string {
if (state.status === "loading") {
// Помилка: у стану loading немає поля data
return state.data.join(", ");
}
return "";
}Події також зручно моделювати як дискриміноване об’єднання. Дискримінатором зазвичай є поле type:
type RequestEvent =
| { type: "start"; startedAt: number }
| { type: "resolve"; data: string[] }
| { type: "reject"; message: string; retryable: boolean }
| { type: "reset" };Кожен тип події має лише потрібні йому дані:
"start" містить час початку;
"resolve" містить результат;
"reject" містить інформацію про помилку;
"reset" не має додаткових полів.
Обробник подій може безпечно використовувати ці поля:
function describeEvent(event: RequestEvent): string {
switch (event.type) {
case "start":
return `Початок о ${event.startedAt}`;
case "resolve":
return `Отримано ${event.data.length} елементів`;
case "reject":
return event.retryable
? `Помилка, доступна повторна спроба: ${event.message}`
: `Неповторювана помилка: ${event.message}`;
case "reset":
return "Стан скинуто";
}
}Замість цього можна було б оголосити одну подію з великою кількістю необов’язкових полів, але тоді TypeScript не зміг би гарантувати узгодженість події:
type UnsafeEvent = {
type: string;
data?: string[];
message?: string;
retryable?: boolean;
};У такій моделі дозволена подія без зрозумілого набору даних:
const unclearEvent: UnsafeEvent = {
type: "resolve",
message: "Несподіване повідомлення",
};Дискриміноване об’єднання не допускає подібних комбінацій.
neverОбробник може підтримувати всі варіанти сьогодні, але стати неповним після додавання нового стану або події.
Для перевірки повноти використовують функцію, параметр якої має тип never:
function assertNever(value: never): never {
throw new Error(`Необроблений варіант: ${String(value)}`);
}Якщо switch опрацював усі варіанти об’єднання, у гілці default змінна має тип never:
function renderState(state: RequestState): string {
switch (state.status) {
case "idle":
return "<p>Очікування</p>";
case "loading":
return "<p>Завантаження...</p>";
case "success":
return `<ul>${state.data
.map((item) => `<li>${item}</li>`)
.join("")}</ul>`;
case "error":
return `<p class="error">${state.message}</p>`;
default:
return assertNever(state);
}
}Якщо додати новий варіант:
type ExtendedRequestState =
| RequestState
| { status: "cancelled"; reason: string };і використати його з функцією, яка очікує всі варіанти, TypeScript повідомить про помилку в assertNever. Це сигналізує, що новий стан потрібно явно обробити.
Перевага такого підходу — помилка з’являється під час перевірки коду, а не після появи нового стану в застосунку.
Дискриміновані об’єднання добре підходять для функцій, які перетворюють поточний стан під дією події.
type RequestState =
| { status: "idle" }
| { status: "loading"; startedAt: number }
| { status: "success"; data: string[] }
| { status: "error"; message: string; retryable: boolean };
type RequestEvent =
| { type: "start"; startedAt: number }
| { type: "resolve"; data: string[] }
| { type: "reject"; message: string; retryable: boolean }
| { type: "reset" };
function assertNever(value: never): never {
throw new Error(`Необроблений варіант: ${String(value)}`);
}
function reduceRequest(
state: RequestState,
event: RequestEvent,
): RequestState {
switch (event.type) {
case "start":
return {
status: "loading",
startedAt: event.startedAt,
};
case "resolve":
return {
status: "success",
data: event.data,
};
case "reject":
return {
status: "error",
message: event.message,
retryable: event.retryable,
};
case "reset":
return {
status: "idle",
};
default:
return assertNever(event);
}
}
function renderState(state: RequestState): string {
switch (state.status) {
case "idle":
return "Очікування";
case "loading":
return `Завантаження розпочато: ${state.startedAt}`;
case "success":
return `Успішно: ${state.data.join(", ")}`;
case "error":
return state.retryable
? `Помилка: ${state.message} (можна повторити)`
: `Помилка: ${state.message} (повторити не можна)`;
default:
return assertNever(state);
}
}
let state: RequestState = { status: "idle" };
const events: RequestEvent[] = [
{ type: "start", startedAt: Date.now() },
{ type: "resolve", data: ["TypeScript", "JavaScript"] },
];
for (const event of events) {
state = reduceRequest(state, event);
console.log(renderState(state));
}У цьому прикладі:
RequestState описує всі дозволені стани.
RequestEvent описує всі дозволені події.
reduceRequest перетворює стан на новий стан.
renderState працює з кожним варіантом стану окремо.
assertNever гарантує, що нові варіанти не залишаться необробленими.
Кожне поле доступне лише в тому місці, де воно справді існує.
Саме об’єднання станів гарантує коректну форму кожного стану, але не обов’язково гарантує правильність кожного переходу.
Наприклад, наведений reducer дозволяє обробити подію "resolve" навіть тоді, коли поточний стан "idle":
const nextState = reduceRequest(
{ status: "idle" },
{ type: "resolve", data: ["дані"] },
);Результат типобезпечний за структурою, але правилами предметної області такий перехід може бути заборонений.
Якщо порядок переходів важливий, його потрібно явно перевіряти:
function reduceRequestStrict(
state: RequestState,
event: RequestEvent,
): RequestState {
switch (state.status) {
case "idle":
if (event.type === "start") {
return {
status: "loading",
startedAt: event.startedAt,
};
}
return state;
case "loading":
switch (event.type) {
case "resolve":
return {
status: "success",
data: event.data,
};
case "reject":
return {
status: "error",
message: event.message,
retryable: event.retryable,
};
case "reset":
return { status: "idle" };
case "start":
return state;
}
case "success":
if (event.type === "reset") {
return { status: "idle" };
}
return state;
case "error":
if (event.type === "start" && state.retryable) {
return {
status: "loading",
startedAt: event.startedAt,
};
}
if (event.type === "reset") {
return { status: "idle" };
}
return state;
}
}Тут типи описують форму стану, а умови всередині reducer — дозволені бізнес-правила переходів.
Дискримінатор має бути достатньо точним. Рядковий літерал "success" — це не те саме, що загальний тип string.
Правильно:
type SuccessState = {
status: "success";
data: string[];
};Якщо використати string, TypeScript втратить інформацію про конкретний варіант:
type TooGeneralState = {
status: string;
data: string[];
};У такій моделі поле status може містити будь-який рядок, тому воно не виконує роль надійного дискримінатора.
Для створення значень важливо також не розширити літеральний тип без потреби:
const successState: SuccessState = {
status: "success",
data: ["готово"],
};Оскільки об’єкт перевіряється проти SuccessState, значення status відповідає потрібному літералу.
Функція може приймати об’єднання і повертати результат, який залежить від його варіанта:
type Result =
| { ok: true; value: number }
| { ok: false; error: string };
function formatResult(result: Result): string {
if (result.ok) {
return `Результат: ${result.value}`;
}
return `Помилка: ${result.error}`;
}Перевірка result.ok зв’язана з відповідним набором полів:
якщо ok: true, доступне value;
якщо ok: false, доступне error.
Такий підхід часто безпечніший за повернення number | undefined, оскільки він змушує код явно обробити обидва сценарії.
Це об’єднання складніше звужувати:
type WeakState =
| { data: string[] }
| { message: string };TypeScript може перевіряти наявність окремих властивостей, але явне поле status робить модель зрозумілішою та стабільнішою:
type StrongState =
| { status: "success"; data: string[] }
| { status: "error"; message: string };Дискримінатор має однозначно визначати варіант. Не слід використовувати однакове значення для різних форм:
type Ambiguous =
| { status: "done"; data: string[] }
| { status: "done"; message: string };Перевірка status === "done" не скаже, який саме варіант отримано.
type IncorrectState = {
status: "idle" | "loading" | "success" | "error";
data?: string[];
message?: string;
};Такий тип дозволяє стан "idle" із data або стан "success" без data. Краще пов’язувати поля з конкретними літеральними значеннями status.
switchЯкщо в switch немає default з assertNever, додавання нового варіанта може залишитися непоміченим у функції, яка не має явної перевірки повноти.
function assertNever(value: never): never {
throw new Error(`Необроблений варіант: ${String(value)}`);
}Використовуйте його в обробниках, де всі варіанти мають бути обов’язково розглянуті.
const data = (state as { data: string[] }).data;Таке приведення вимикає частину перевірок TypeScript. Воно може приховати помилку, якщо state насправді не є успішним станом.
Надійніше спочатку звузити тип через дискримінатор:
if (state.status === "success") {
return state.data;
}Дискриміноване об’єднання описує кілька взаємовиключних форм одного типу.
Спільне поле-літерал, наприклад status або type, називається дискримінатором.
Після перевірки дискримінатора TypeScript безпечно звужує тип.
Стани та події краще моделювати окремими варіантами, а не набором необов’язкових полів.
Події зручно обробляти в switch.
Функція з параметром never допомагає перевірити повноту обробки.
Дискриміновані об’єднання гарантують коректну форму даних, але правила переходів між станами потрібно реалізувати окремо.