Давайте поговоримо про тип never
. Коли його використовувати, як він працює, та чому це потужний інструмент в вашому арсеналі TypeScript.
Коли ви шукаєте інформацію про те, як щось працює, зокрема в TypeScript, завжди варто починати з офіційної документації.
Єдина проблема в тому, що в TypeScript інформація в документації може бути розкидана по різних розділах. А втім, ви завжди можете скористатися пошуком
як на прикладі нижче:
Що таке never
?
Перша згадка в документації каже нам про те, що never
- це bottom type (найнижчий тип), а unknown
- це top type (верхній тип)

Top type
Top type (верхній тип) - це той тип, який є супертипом для всіх інших. І навпаки, всі інші типи будуть для нього підтипами. Мені до вподоби таке пояснення: кожен тип в системі типів може бути представлений певною множиною типів - кінцевою або нескінченною.
На малюнку знизу ви можете побачити велику блакитну бульбашку - це нескінченна множина типів, які входять в unknown
.
В цю бульбашку, наприклад входить бузкінечна множина типів string
, нескінченна множина типів number
, кінцева множина типів boolean
(true
і false
), типи null
і undefined
та інші.
Bottom type
Натомість, bottom type (найнижчий тип) - це тип, який не є супертипом для жодного іншого типу. Але всі інші типи - це супертипи для never
.
Тобто never
- це пуста множина типів. Якщо ми хотіли б представити never
на малюнку, то це була б порожня бульбашка, чи навіть відсутність бульбашки. never
- це нічого. В деяких мовах програмування, таких як Kotlin, Scala та Ceylon - нижній тип так і назвається - Nothing
.
Повертаючись до попередніх тверджень і аналогії з бульбашкою:
never
- не є супертипом для жодного іншого типу” - значить, що жоден інший тип, жодна множина типів, жодна бульбашка не може вміститися вnever
. Ми не можемо вміститищось
внічого
.
declare let neverVar: never;
let someString: string = "Hello";
let someNumber: number = 42;
let someBoolean: boolean = true;
// Спробуємо присвоїти змінній типу `never` значення інших типів
// Ці рядки викличуть помилки компіляції:
neverVar = someString; // Error: Type 'string' is not assignable to type 'never'
neverVar = someNumber; // Error: Type 'number' is not assignable to type 'never'
neverVar = someBoolean; // Error: Type 'boolean' is not assignable to type 'never'
- всі інші типи - це супертипи для
never
- значить, що будь-який інший тип, будь-яка множина типів, будь яка бульбашка вміщає в собіnever
. Тому щоnever
- це пустота і нічого. Хіба не знайдеться місця для пустоти в будь-якій бульбашці? :)
let stringVar: string;
let numberVar: number;
let booleanVar: boolean;
let objectVar: object;
// Припустимо, що маємо змінну типу `never`:
declare let neverVar: never;
// Значення `never` може бути присвоєне змінним будь-якого іншого типу:
stringVar = neverVar; // Valid
numberVar = neverVar; // Valid
booleanVar = neverVar; // Valid
objectVar = neverVar; // Valid
Ці твердження також присутні в наступному фрагменті документації TypeScript де розглядається таблиця субтипів, в тому числі і дляnever
.
Практичне застосування never
Функції, які ніколи не повертають значення
Перше, що приходить на думку, і те, що ви можете знайти в офіційній документації - це приклад, коли функції не повертає значення:
function fail(msg: string): never {
throw new Error(msg);
}
function infiniteLoop(): never {
while (true) {
// Безкінечний цикл
}
}
Тут варто звернути увагу на те, що в обох функціях явно вказано, що вони повертають never
. Якщо прибрати явний return type, то TypeScript неявно присвоїть (infer) тип void
:
function fail(msg: string)/*: void*/ {
throw new Error(msg);
}
function infiniteLoop()/*: void*/ {
while (true) {
// Безкінечний цикл
}
}
Різниця між void
та never
Згідно документації void
- представляє значення, що повертається функціями, які не повертають значення. Це виведений (inferred) тип кожного разу, коли функція не має return
або не повертає жодного явного значення в return
:
// The inferred return type is void
function noop() {
return;
}
Різниця між void
та never
доволі тонка,
void
не гарантує того, що функція дійсно не повертає ніякого значення. void
гарантує тільки те, що щоб функція не повернула, це значення буде ігноровано і не зможе бути використано в коді.
type VoidFunc = () => void;
const f1: VoidFunc = () => {
return true;
};
const f2: VoidFunc = () => true;
const f3: VoidFunc = function () {
return true;
};
//Error: Type 'void' is not assignable to type 'boolean'.
const a: boolean = f1()
//Error: Type 'void' is not assignable to type 'boolean'.
const b: boolean = f2()
//Error: Type 'void' is not assignable to type 'boolean'.
const b: boolean = f3()
Натомість, never
гарантує, що функція дійсно не повертає ніякого значення. Це означає, що функція завжди кидає помилку, викликає нескінченний цикл або завершує виконання програми.
type NeverReturnsFunc = () => never
type VoidFunc = () => void
const fail: NeverReturnsFunc = () => {
throw new Error()
}
const infiniteLoop: NeverReturnsFunc = () => {
while (true) {
}
}
//Error: Type '() => void' is not assignable to type 'NeverReturnsFunc
const voidFunc: NeverReturnsFunc = () => {
}
//Error: A function returning 'never' cannot have a reachable end point.
function voidFunc2(): never {
}
Стосовно void
існує ще один особливий випадок, про який слід пам’ятати - коли ви явно вказуєте void
в return type, то ця функція не повинна нічого повертати, інакше (очікувано) отримаєте помилку:
function f1(): void {
//Error: Type 'boolean' is not assignable to type 'void'.
return true;
}
const f2 = (): void => {
//Error: Type 'boolean' is not assignable to type 'void'.
return true;
}
type VoidFunc = () => void
const f3: VoidFunc = () => {
//It works, but looks bad
return true;
}
never
в switch
та if
Доволі цікавим є застосування never
для того, щоб переконатися, що всі можливі варіанти в switch
або if
були враховані:
type Animal = "cat" | "dog" | "bird";
function handleAnimal(animal: Animal): string {
switch (animal) {
case "cat":
return "This is a cat";
case "dog":
return "This is a dog";
case "bird":
return "This is a bird";
default:
// Якщо додати інший тип до `Animal`, TypeScript покаже помилку.
const exhaustiveCheck: never = animal;
throw new Error(`Unknown animal: ${animal}`);
}
}
У цьому прикладі ми використовуємо never
для exhaustiveCheck
, щоб перевірити, що всі можливі варіанти значень типу Animal
були оброблені.
Якщо ми додамо новий тип в Animal
(наприклад, "fish"
), TypeScript підсвітить помилку, вказуючи, що потрібно додати ще одну умову до switch
.
Якщо розвивати цю ідею, можна створити допоміжну функцію, яка буде приймати аргумент, якого не має існувати, і викидати помилку:
function assertUnreachable(value: never): never {
throw new Error(`Missed a case! ${value}`);
}
Тоді попередній приклад можна переписати наступним чином:
function handleAnimal(animal: Animal): string {
switch (animal) {
case "cat":
return "This is a cat";
case "dog":
return "This is a dog";
case "bird":
return "This is a bird";
default:
return assertUnreachable(animal);
}
}
А втім, якщо ви використовувєте лінтери, зокрема typescript-eslint
, то в останньому є правило switch-exhaustiveness-check.
Якщо певні перевірки можна виконати автоматично, то чому б не скористатися цим? :)
never
для виняткового ‘або’ (виключна диз’юнкція / exclusive or / XOR)
В Typescript “або” (or
) - це інклюзивне “або”, тобто, якщо ви маєте об’єднання типів (union) A | B
, то це інтерпритується як A
, B
, або обидва.
Це добре іллюструє наступний приклад
interface TextMessage {
text: string;
}
interface AttachmentMessage {
attachment: string;
}
type ChatMessage = TextMessage | AttachmentMessage;
const messageOne: ChatMessage = { text: 'Hello, world!' };
const messageTwo: ChatMessage = { attachment: 'https://example.com/image.jpg' };
const messageThree: ChatMessage = { text: 'Hello!', attachment: 'https://example.com/image.jpg' };
Всі три варіант вище будуть валідними, оскільки ChatMessage
може бути або TextMessage
, або AttachmentMessage
, або й тим й тим.
Але якщо ми хочемо, щоб ChatMessage
був або TextMessage
, або AttachmentMessage
, але не обидва одночасно, то ми можемо використати never
:
interface TextMessage {
text: string;
attachment?: never;
}
interface AttachmentMessage {
text?: never;
attachment: string;
}
type ChatMessage = TextMessage | AttachmentMessage;
const messageOne: ChatMessage = { text: 'Hello, world!' }; // Valid
const messageTwo: ChatMessage = { attachment: 'https://example.com/image.jpg' }; // Valid
const messageThree: ChatMessage = { text: 'Hello!', attachment: 'https://example.com/image.jpg' }; // Error: Type '{ text: string; attachment: string; }' is not assignable to type 'ChatMessage'.
Або більш універсальне рішення - допоміжний тип XOR
:
type XOR<T1, T2> =
(T1 & {[k in Exclude<keyof T2, keyof T1>]?: never}) |
(T2 & {[k in Exclude<keyof T1, keyof T2>]?: never});
type ChatMessage = XOR<TextMessage, AttachmentMessage>;
Варто ж однак зазначити, що хоча такий підхід є повністню коректним, в TypeScript більш прийнято використовувти теговані обʼєднання (tagged unions / discriminated unions)
Тег (tag/discriminant) - це додаткове поле, яке вказує на тип об’єкта. Це дозволяє TypeScript визначити, який тип об’єкта використовується в конкретному випадку.
interface TextMessage {
type: 'text';
text: string;
}
interface AttachmentMessage {
type: 'attachment';
attachment: string;
}
type ChatMessage = TextMessage | AttachmentMessage;
const messageOne: ChatMessage = { type: 'text', text: 'Hello, world!' };
В прикладі вище, тег - це поле type
, яке вказує на тип об’єкта. Якщо ми випадково вкажемо обидва поля, то TypeScript підсвітить помилку:
const messageThree: ChatMessage = { type: 'text', text: 'Hello!', attachment: 'https://example.com/image.jpg' }; // Error: Object literal may only specify known properties, and 'attachment' does not exist in type 'TextMessage'.
Ба більше, з таким підходом працювати з типом далі набагато зручніше, порівняйте самі:
Припустимо, що в нас є функція, яка обробляє повідомлення. Якщо це tagged union підхід, то це виглядає наступним чином:
function handleMessage(message: ChatMessage) {
switch (message.type) {
case 'text':
console.log(message.text);
break;
case 'attachment':
console.log(message.attachment);
break;
default:
assertUnreachable(message);
}
}
А якщо ми використовуємо XOR підхід, то це виглядає так:
function handleMessage(message: XOR<TextMessage, AttachmentMessage>) {
if ('text' in message) {
console.log(message.text);
} else {
console.log(message.attachment);
}
}
Як на мене, перший варіант виглядає більш читабельним і зрозумілим. Особливо, різниця буде помітна при маштабуванні коду.
never
в умовних типах (conditional types)
Де особливо я доволі часто стикався з never
- так це - при написанні допоміжних типів (utility types). Доречі, є гарний репозиторій - Type Challanges, де ви можете попрактикуватися в написанні таких типів.
Цей допоміжний тип виключає з типу значення null
та undefined
, повертаючи never для будь-якого типу, що містить їх.
type NonNullable<T> = T extends null | undefined ? never : T;
// Приклади використання:
type A = NonNullable<boolean>; // boolean
type B = NonNullable<number | null>; // number
type C = NonNullable<string | undefined>; // string
Це - копія вбудованного допоміжного типу Exclude
- він видаляє з типу всі підтипи, які відповідають заданому підтипу.
type Exclude<T, U> = T extends U ? never : T;
// Приклади використання:
type Union = string | number | boolean;
type ExcludeString = Exclude<Union, string>; // number | boolean
type ExcludeNumber = Exclude<Union, number>; // string | boolean
Цей utility тип витягує значення з Promise
, використовуючи never
для всіх типів, які не є Promise.
type PromiseValue<T> = T extends Promise<infer R> ? R : never;
// Приклади використання:
type Value1 = PromiseValue<Promise<string>>; // string
type Value2 = PromiseValue<Promise<number>>; // number
type Value3 = PromiseValue<number>; // never (не є Promise)
Розподіл над обʼєднаннями (distribution over unions) - особливість never
Працюючи з умовними типами, можна наткнутися на один з підводних каменів never
- це те як він розподіляється над об’єднаннями.
Давайте розглянемо наступний приклад. В нас є умовний тип:
type BeOrNotToBe<T> = T extends "Something is rotten in the state of Denmark" ? "Be" : "Not to be";
Якщо ми передамо в цей тип строку "Something is rotten in the state of Denmark"
, то він поверне "Be"
, в іншому випадку - "Not to be"
.
type GuesWhat = BeOrNotToBe<'Gues what'>
// ^? type GuesWhat = "Not to be"
Якщо ми передамо в цей тип union, то TypeScript розподілить тип над кожним елементом об’єднання. І приклад нижче поверне об’єднання типів "Not to be" | "Be"
:
type MaybeBoth = BeOrNotToBe<'Gues what' | "Something is rotten in the state of Denmark">
// type MaybeBoth = BeOrNotToBe<"Gues what'"> | BeOrNotToBe<"Something is rotten in the state of Denmark">
// type MaybeBoth = "Not to be" | "Be"
Але що буде, якщо ми передамо в цей тип never
?
type AndNow = BeOrNotToBe<never>;
// type AndNow = never
Ми би могли очікувати, що AndNow
буде "Not to be"
, але ні.
Чому це так відбувається як цьому запобігти?
Відбувається це тому, що TypeScript розглядає тип never
як порожнє об’єднання. І якщо немає чого розподіляти, то ви отримаєте порожність назад.
Щоб уникнути неявного розподілу, можна використати []
:
type BeOrNotToBe<T> = [T] extends ["Something is rotten in the state of Denmark"] ? "Be" : "Not to be";
type AndNow = BeOrNotToBe<never>;
Як гадаєте, що буде в AndNow
? Першою думкою може бути, що це буде "Not to be"
, але ні. Це буде Be
. Чому?
Памʼятаєте, на початку ми визначили, що всі існуючі типи - це супертипи для never
, тому, коли ми перевіряємо, чи never
є підтипом для "Something is rotten in the state of Denmark"
, то виявляється, що це так, never
є підтипом для будь-якого типу.
Якщо ви не зрозуміли цього з першого разу, не переймайтесь, це досить складний момент, який варто розглянути кілька разів, розуміючи як працює розподіл над об’єднаннями (distribution over unions) і як TypeScript працює з never
.
Фінал
Вітаю, ви дійшли до кінця цієї статті. Ми з вами розглянули що таке never
і як його використовувати. Сподіваюсь, що ця стаття була корисною для вас. Якщо у вас є якісь питання або зауваження, звертайтесь на будь-який із зазначених контактів . Дякую за увагу!