Пошук уроків, статей та іншого контенту
Використовуйте never для перевірки повноти обробки всіх варіантів об’єднання під час компіляції.
Об’єднання типів (union) може описувати скінченний набір допустимих варіантів:
type Status = "pending" | "success" | "failure";Якщо код обробляє значення типу Status, важливо не пропустити жоден із варіантів. Особливо це актуально, коли об’єднання розширюється:
type Status = "pending" | "success" | "failure" | "cancelled";Після такої зміни всі місця, де Status обробляється через switch, if або інші розгалуження, потенційно потребують оновлення.
Exhaustiveness checking — це перевірка того, що код обробляє всі можливі варіанти об’єднання. TypeScript може виконувати її під час компіляції за допомогою типу never.
nevernever описує значення, яке неможливо отримати.
Він використовується для:
функцій, які ніколи не завершуються звичайним способом;
значень, які після звуження типу не можуть існувати;
перевірки повноти обробки всіх варіантів об’єднання.
Наприклад:
function fail(message: string): never {
throw new Error(message);
}Функція fail завжди завершується винятком, тому вона не повертає значення жодного звичайного типу. Її тип результату — never.
Важлива властивість never:
let impossible: never;Жодне звичайне значення не можна присвоїти змінній типу never. Якщо TypeScript дозволяє передати значення функції, яка очікує never, це означає, що після попередніх перевірок значення справді стало неможливим.
switchРозглянемо об’єднання з усіма можливими статусами:
type Status = "pending" | "success" | "failure";
function getMessage(status: Status): string {
switch (status) {
case "pending":
return "Операція виконується";
case "success":
return "Операція успішна";
case "failure":
return "Операція завершилася помилкою";
}
}На рівні логіки всі варіанти оброблено. Проте сам switch не повідомляє TypeScript, що список варіантів має бути повним. Для явної перевірки створюють функцію, яка приймає лише never:
function assertNever(value: never): never {
throw new Error(`Необроблений варіант: ${String(value)}`);
}Тепер її можна викликати в гілці default:
type Status = "pending" | "success" | "failure";
function assertNever(value: never): never {
throw new Error(`Необроблений варіант: ${String(value)}`);
}
function getMessage(status: Status): string {
switch (status) {
case "pending":
return "Операція виконується";
case "success":
return "Операція успішна";
case "failure":
return "Операція завершилася помилкою";
default:
return assertNever(status);
}
}
console.log(getMessage("success"));У кожній гілці switch TypeScript звужує тип status. Після обробки "pending", "success" і "failure" у default не має залишитися жодного можливого значення. Тому там status має тип never, і виклик assertNever(status) коректний.
Додамо новий статус:
type Status = "pending" | "success" | "failure" | "cancelled";Але не додаватимемо відповідний case:
type Status = "pending" | "success" | "failure" | "cancelled";
function assertNever(value: never): never {
throw new Error(`Необроблений варіант: ${String(value)}`);
}
function getMessage(status: Status): string {
switch (status) {
case "pending":
return "Операція виконується";
case "success":
return "Операція успішна";
case "failure":
return "Операція завершилася помилкою";
default:
return assertNever(status);
}
}Тепер у default змінна status має тип "cancelled", а не never. TypeScript повідомить про помилку на виклику:
Argument of type '"cancelled"' is not assignable to parameter of type 'never'.Ця помилка виникає під час компіляції, ще до запуску програми. Вона вказує, що після зміни об’єднання потрібно доповнити switch:
type Status = "pending" | "success" | "failure" | "cancelled";
function assertNever(value: never): never {
throw new Error(`Необроблений варіант: ${String(value)}`);
}
function getMessage(status: Status): string {
switch (status) {
case "pending":
return "Операція виконується";
case "success":
return "Операція успішна";
case "failure":
return "Операція завершилася помилкою";
case "cancelled":
return "Операцію скасовано";
default:
return assertNever(status);
}
}Найчастіше цей підхід використовують із об’єднаннями об’єктів, які мають спільне поле-дискримінатор:
type Result =
| {
kind: "success";
data: string;
}
| {
kind: "error";
message: string;
}
| {
kind: "loading";
};Поле kind дозволяє TypeScript визначити конкретний варіант об’єкта:
function assertNever(value: never): never {
throw new Error(`Необроблений результат: ${JSON.stringify(value)}`);
}
function renderResult(result: Result): string {
switch (result.kind) {
case "success":
return `Дані: ${result.data}`;
case "error":
return `Помилка: ${result.message}`;
case "loading":
return "Завантаження...";
default:
return assertNever(result);
}
}
console.log(renderResult({ kind: "success", data: "Профіль користувача" }));
console.log(renderResult({ kind: "loading" }));У кожній гілці TypeScript не лише перевіряє значення kind, а й звужує весь об’єкт:
у case "success" доступне поле data;
у case "error" доступне поле message;
у case "loading" немає додаткових полів;
у default об’єкт має бути типу never.
Якщо додати новий варіант:
type Result =
| {
kind: "success";
data: string;
}
| {
kind: "error";
message: string;
}
| {
kind: "loading";
}
| {
kind: "cancelled";
reason: string;
};TypeScript вимагатиме обробити "cancelled" у renderResult.
assertNever має повертати neverТип результату never важливий для коректного аналізу потоку виконання:
function assertNever(value: never): never {
throw new Error(`Необроблений варіант: ${String(value)}`);
}Оскільки функція ніколи не повертає значення, TypeScript розуміє, що після її виклику виконання не продовжується.
Тому можна написати:
function getMessage(status: Status): string {
switch (status) {
case "pending":
return "Операція виконується";
case "success":
return "Операція успішна";
case "failure":
return "Операція завершилася помилкою";
default:
return assertNever(status);
}
}Виклик assertNever є виразом типу never, а never сумісний із string. Проте фактично цей рядок ніколи не повертається, оскільки функція завершується винятком.
never можна використовувати не лише в switch. Той самий підхід працює з послідовними перевірками:
type Priority = "low" | "normal" | "high";
function assertNever(value: never): never {
throw new Error(`Невідома пріоритетність: ${String(value)}`);
}
function getPriorityNumber(priority: Priority): number {
if (priority === "low") {
return 1;
}
if (priority === "normal") {
return 2;
}
if (priority === "high") {
return 3;
}
return assertNever(priority);
}
console.log(getPriorityNumber("high"));Після трьох перевірок priority звужується до never. Якщо до Priority додати, наприклад, "urgent", останній виклик перестане бути коректним, доки для нового варіанта не буде додано умову.
Для великої кількості варіантів switch зазвичай читається краще, але механізм перевірки однаковий.
defaultЗвичайна гілка default сама по собі не гарантує exhaustiveness checking:
function getMessage(status: Status): string {
switch (status) {
case "pending":
return "Операція виконується";
case "success":
return "Операція успішна";
default:
return "Інший статус";
}
}Такий код компілюється, навіть якщо в Status з’являється новий варіант. Але помилка не виникає, тому що default навмисно приймає будь-яке значення, яке не збіглося з попередніми case.
Для перевірки повноти default має передавати значення до функції, яка приймає never:
default:
return assertNever(status);Це перетворює непередбачений варіант із мовчазного fallback у помилку компіляції.
Exhaustiveness checking корисний не лише для функцій, що повертають рядок або число. Його можна застосовувати у функціях із побічними ефектами:
type Event =
| { type: "created"; id: string }
| { type: "deleted"; id: string };
function assertNever(value: never): never {
throw new Error(`Невідомий тип події: ${JSON.stringify(value)}`);
}
function handleEvent(event: Event): void {
switch (event.type) {
case "created":
console.log(`Створено: ${event.id}`);
break;
case "deleted":
console.log(`Видалено: ${event.id}`);
break;
default:
assertNever(event);
}
}
handleEvent({ type: "created", id: "user-42" });У default немає потреби використовувати return, оскільки assertNever не повертає керування функції. Якщо до Event додати новий тип, компілятор вкаже на пропущену обробку.
anyЯкщо дискримінатор або об’єкт має тип any, TypeScript не може виконати надійну перевірку:
function handle(value: any): void {
// Перевірка повноти тут неможлива.
}Exhaustiveness checking працює лише тоді, коли TypeScript знає точний тип об’єднання.
Якщо поле має тип string, а не конкретне об’єднання літералів, компілятор не знає повного списку варіантів:
type Event = {
type: string;
};Для перевірки повноти потрібно описати допустимі значення явно:
type Event =
| { type: "created"; id: string }
| { type: "deleted"; id: string };default замість assertNeverТакий код приховує нові варіанти:
default:
return "Невідомо";Для compile-time перевірки використовуйте:
default:
return assertNever(value);У discriminated union краще передавати до assertNever весь об’єкт:
default:
return assertNever(result);Так TypeScript перевіряє повноту всього об’єднання об’єктів, а не лише окремого значення дискримінатора.
Якщо функція має повертати значення, корисно явно вказувати тип результату:
function getMessage(status: Status): string {
// ...
}Це робить контракт функції очевидним і допомагає компілятору виявляти шляхи, на яких значення може не бути повернуте. Водночас для надійної перевірки саме повноти варіантів використовуйте assertNever.
never описує значення, яке неможливо отримати.
У кінцевій гілці розгалуження повністю оброблене union-значення звужується до never.
Функція assertNever(value: never): never перетворює пропущений варіант на помилку компіляції.
Найпоширеніший шаблон — switch із default, що викликає assertNever.
Для discriminated union перевіряйте весь об’єкт, а не лише його поле-дискримінатор.
Після додавання нового варіанта до об’єднання TypeScript покаже всі місця, де його ще не оброблено.