Пошук уроків, статей та іншого контенту
Моделюйте варіанти даних спільним дискримінатором і звужуйте об’єднання через його значення.
Дискриміноване об’єднання — це об’єднання кількох типів, кожен з яких має спільну властивість-дискримінатор. Значення дискримінатора має різні літеральні типи для різних варіантів даних.
Наприклад, повідомлення може бути текстовим, зображенням або системним:
type TextMessage = {
type: "text";
text: string;
};
type ImageMessage = {
type: "image";
url: string;
width: number;
height: number;
};
type SystemMessage = {
type: "system";
code: number;
};
type Message = TextMessage | ImageMessage | SystemMessage;Властивість type є дискримінатором. Її значення визначає, який саме варіант об’єкта використовується:
"text" — TextMessage;
"image" — ImageMessage;
"system" — SystemMessage.
Завдяки цьому TypeScript може звужувати тип об’єднання після перевірки type.
Без звуження змінна типу Message може бути будь-яким із трьох варіантів. Тому TypeScript не дозволить безпечно звернутися, наприклад, до властивості text:
function getMessageDescription(message: Message): string {
// Помилка: властивість text існує не в усіх варіантах Message
return message.text;
}Спочатку потрібно перевірити значення дискримінатора:
function getMessageDescription(message: Message): string {
if (message.type === "text") {
return message.text;
}
if (message.type === "image") {
return `Зображення ${message.width}×${message.height}: ${message.url}`;
}
return `Системне повідомлення, код ${message.code}`;
}У першій гілці TypeScript знає, що message має тип TextMessage. У другій — ImageMessage. Після перевірок, що залишилися, — SystemMessage.
Для звуження можна використовувати:
if;
else if;
switch;
тернарний оператор;
перевірки з === або switch для значення дискримінатора.
switchswitch добре підходить, коли варіантів кілька:
function formatMessage(message: Message): string {
switch (message.type) {
case "text":
return `Текст: ${message.text}`;
case "image":
return `Зображення: ${message.url}`;
case "system":
return `Система: ${message.code}`;
}
}У кожній гілці TypeScript автоматично звужує тип message:
у case "text" доступні властивості TextMessage;
у case "image" доступні властивості ImageMessage;
у case "system" доступні властивості SystemMessage.
Нижче наведено самодостатній приклад обробки подій користувача:
type UserCreatedEvent = {
type: "user.created";
userId: string;
email: string;
};
type UserDeletedEvent = {
type: "user.deleted";
userId: string;
reason: string;
};
type PasswordChangedEvent = {
type: "password.changed";
userId: string;
changedAt: Date;
};
type UserEvent =
| UserCreatedEvent
| UserDeletedEvent
| PasswordChangedEvent;
function describeEvent(event: UserEvent): string {
switch (event.type) {
case "user.created":
return `Створено користувача ${event.userId} з email ${event.email}`;
case "user.deleted":
return `Видалено користувача ${event.userId}. Причина: ${event.reason}`;
case "password.changed":
return `Пароль користувача ${event.userId} змінено ${event.changedAt.toISOString()}`;
}
}
const events: UserEvent[] = [
{
type: "user.created",
userId: "u-101",
email: "olena@example.com",
},
{
type: "user.deleted",
userId: "u-102",
reason: "Запит користувача",
},
{
type: "password.changed",
userId: "u-101",
changedAt: new Date("2025-01-15T10:00:00Z"),
},
];
for (const event of events) {
console.log(describeEvent(event));
}Тип UserEvent описує всі допустимі варіанти подій. Функція describeEvent не працює з довільним об’єктом, а приймає лише один із цих варіантів.
Під час розширення об’єднання важливо не пропустити обробку нового варіанта.
Для цього використовують функцію, яка приймає never:
function assertNever(value: never): never {
throw new Error(`Необроблений варіант: ${JSON.stringify(value)}`);
}never означає, що значення не може існувати. Якщо switch обробив усі варіанти об’єднання, у гілці default значення справді має тип never.
type SuccessResult = {
status: "success";
data: string;
};
type ErrorResult = {
status: "error";
message: string;
};
type LoadingResult = {
status: "loading";
};
type Result = SuccessResult | ErrorResult | LoadingResult;
function assertNever(value: never): never {
throw new Error(`Необроблений варіант: ${JSON.stringify(value)}`);
}
function renderResult(result: Result): string {
switch (result.status) {
case "success":
return `Дані: ${result.data}`;
case "error":
return `Помилка: ${result.message}`;
case "loading":
return "Завантаження...";
default:
return assertNever(result);
}
}Тепер додамо новий варіант:
type EmptyResult = {
status: "empty";
};
type ExtendedResult =
| SuccessResult
| ErrorResult
| LoadingResult
| EmptyResult;Якщо змінити параметр функції на ExtendedResult, але не додати case "empty", TypeScript повідомить про помилку в рядку з assertNever(result). Це сигналізує, що новий варіант ще не оброблено.
Повний варіант із перевіркою повноти:
type SuccessResult = {
status: "success";
data: string;
};
type ErrorResult = {
status: "error";
message: string;
};
type LoadingResult = {
status: "loading";
};
type EmptyResult = {
status: "empty";
};
type Result =
| SuccessResult
| ErrorResult
| LoadingResult
| EmptyResult;
function assertNever(value: never): never {
throw new Error(`Необроблений варіант: ${JSON.stringify(value)}`);
}
function renderResult(result: Result): string {
switch (result.status) {
case "success":
return `Дані: ${result.data}`;
case "error":
return `Помилка: ${result.message}`;
case "loading":
return "Завантаження...";
case "empty":
return "Даних немає";
default:
return assertNever(result);
}
}
console.log(renderResult({ status: "loading" }));
console.log(renderResult({ status: "success", data: "Профіль користувача" }));
console.log(renderResult({ status: "empty" }));Дискримінатор повинен мати літеральний тип, наприклад "success", а не загальний тип string.
Об’єкт, створений без явної анотації, зазвичай правильно перевіряється під час передачі у функцію:
type Success = {
status: "success";
data: string;
};
function printSuccess(result: Success): void {
console.log(result.data);
}
printSuccess({
status: "success",
data: "Готово",
});Але якщо об’єкт спочатку зберігається у змінній, його властивості можуть бути розширені до загальніших типів:
const result = {
status: "success",
data: "Готово",
};
// status може бути виведено як stringДля надійної перевірки використовуйте тип:
const result: Success = {
status: "success",
data: "Готово",
};Або оператор satisfies, який перевіряє відповідність типу й водночас зберігає точні типи властивостей:
type Success = {
status: "success";
data: string;
};
const result = {
status: "success",
data: "Готово",
} satisfies Success;Дискримінатором може бути не лише властивість із назвою type. Наприклад, для результату HTTP-запиту можна використовувати ok:
type ApiSuccess<T> = {
ok: true;
data: T;
};
type ApiFailure = {
ok: false;
error: string;
};
type ApiResponse<T> = ApiSuccess<T> | ApiFailure;
function getUserName(
response: ApiResponse<{ name: string }>,
): string {
if (response.ok) {
return response.data.name;
}
return `Помилка: ${response.error}`;
}
console.log(
getUserName({
ok: true,
data: { name: "Олена" },
}),
);
console.log(
getUserName({
ok: false,
error: "Користувача не знайдено",
}),
);Після перевірки if (response.ok) TypeScript розуміє, який варіант використовується:
true означає успішну відповідь із data;
false означає помилку з error.
Дискриміноване об’єднання також зручно використовувати для опису команд:
type AddTodoCommand = {
type: "addTodo";
title: string;
};
type RemoveTodoCommand = {
type: "removeTodo";
id: number;
};
type ToggleTodoCommand = {
type: "toggleTodo";
id: number;
};
type TodoCommand =
| AddTodoCommand
| RemoveTodoCommand
| ToggleTodoCommand;
function executeCommand(command: TodoCommand): string {
switch (command.type) {
case "addTodo":
return `Додано завдання: ${command.title}`;
case "removeTodo":
return `Видалено завдання з ідентифікатором ${command.id}`;
case "toggleTodo":
return `Змінено стан завдання з ідентифікатором ${command.id}`;
}
}
console.log(executeCommand({
type: "addTodo",
title: "Вивчити TypeScript",
}));
console.log(executeCommand({
type: "toggleTodo",
id: 42,
}));Такий підхід робить API функції явним: кожна команда має власний набір властивостей, а недопустимі комбінації відхиляються під час компіляції.
stringНеправильно:
type BadMessage = {
type: string;
text: string;
};Тип string не описує конкретний набір варіантів. TypeScript не може використати його як надійний дискримінатор.
Краще:
type TextMessage = {
type: "text";
text: string;
};Якщо варіанти не мають спільної властивості, звуження стає менш надійним:
type TextContent = {
text: string;
};
type ImageContent = {
url: string;
};
type Content = TextContent | ImageContent;У такому разі можна перевіряти наявність властивості, але явний дискримінатор зазвичай зрозуміліший і краще масштабується:
type TextContent = {
kind: "text";
text: string;
};
type ImageContent = {
kind: "image";
url: string;
};
type Content = TextContent | ImageContent;Неправильно звертатися до властивості до перевірки дискримінатора:
function getUrl(message: Message): string {
// Помилка, оскільки url є лише в ImageMessage
return message.url;
}Потрібно спочатку звузити тип:
function getUrl(message: Message): string | undefined {
if (message.type === "image") {
return message.url;
}
return undefined;
}switchЯкщо функція має обробляти всі варіанти, не залишайте нові значення без обробки. Використовуйте assertNever, щоб компілятор повідомив про пропущений варіант.
Дискриміноване об’єднання складається з кількох варіантів зі спільною властивістю-дискримінатором.
Значення дискримінатора повинні бути літеральними типами, наприклад "success" або "error".
Перевірка дискримінатора звужує об’єднання до конкретного типу.
switch зручно використовувати для обробки всіх варіантів.
Функція з параметром never допомагає перевірити повноту обробки.
Такий підхід підходить для подій, станів, результатів операцій, команд і відповідей API.