Повернутися до блогу
04 лист. 2024 р.
12 хвилин читання

Тип `never`

Повний огляд типу `never` в TypeScript

Давайте поговоримо про тип never . Коли його використовувати, як він працює, та чому це потужний інструмент в вашому арсеналі TypeScript.

Коли ви шукаєте інформацію про те, як щось працює, зокрема в TypeScript, завжди варто починати з офіційної документації. Єдина проблема в тому, що в TypeScript інформація в документації може бути розкидана по різних розділах. А втім, ви завжди можете скористатися пошуком як на прикладі нижче: Typescript Search Docs

Що таке never?

Перша згадка в документації каже нам про те, що never - це bottom type (найнижчий тип), а unknown - це top type (верхній тип)

Never is a bottom type

Top type

Top type (верхній тип) - це той тип, який є супертипом для всіх інших. І навпаки, всі інші типи будуть для нього підтипами. Мені до вподоби таке пояснення: кожен тип в системі типів може бути представлений певною множиною типів - кінцевою або нескінченною.

На малюнку знизу ви можете побачити велику блакитну бульбашку - це нескінченна множина типів, які входять в unknown.

Unknown set

В цю бульбашку, наприклад входить бузкінечна множина типів 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'

Playground Link

  • всі інші типи - це супертипи для 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

Playground Link

Ці твердження також присутні в наступному фрагменті документації TypeScript де розглядається таблиця субтипів, в тому числі і дляnever.

Практичне застосування never

Функції, які ніколи не повертають значення

Перше, що приходить на думку, і те, що ви можете знайти в офіційній документації - це приклад, коли функції не повертає значення:

function fail(msg: string): never {
  throw new Error(msg);
}

function infiniteLoop(): never {
    while (true) {
      // Безкінечний цикл
    }
}

Playground Link

Тут варто звернути увагу на те, що в обох функціях явно вказано, що вони повертають never. Якщо прибрати явний return type, то TypeScript неявно присвоїть (infer) тип void:


function fail(msg: string)/*: void*/ {
  throw new Error(msg);
}

function infiniteLoop()/*: void*/ {
  while (true) {
    // Безкінечний цикл
  }
}

Playground Link

Різниця між 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()

Playground link

Натомість, 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 {
  
}

Playground link

Стосовно 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;
}

Playground link

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' }; 

Playground Link

Всі три варіант вище будуть валідними, оскільки 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'.

Playground Link

Або більш універсальне рішення - допоміжний тип 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>;    

Playground link

Варто ж однак зазначити, що хоча такий підхід є повністню коректним, в 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'.

Playground link

Ба більше, з таким підходом працювати з типом далі набагато зручніше, порівняйте самі:

Припустимо, що в нас є функція, яка обробляє повідомлення. Якщо це 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

Playground link

Ми би могли очікувати, що 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>;

Playground link

Як гадаєте, що буде в AndNow? Першою думкою може бути, що це буде "Not to be", але ні. Це буде Be. Чому?

Памʼятаєте, на початку ми визначили, що всі існуючі типи - це супертипи для never, тому, коли ми перевіряємо, чи never є підтипом для "Something is rotten in the state of Denmark", то виявляється, що це так, never є підтипом для будь-якого типу.

Якщо ви не зрозуміли цього з першого разу, не переймайтесь, це досить складний момент, який варто розглянути кілька разів, розуміючи як працює розподіл над об’єднаннями (distribution over unions) і як TypeScript працює з never.

Фінал

Вітаю, ви дійшли до кінця цієї статті. Ми з вами розглянули що таке never і як його використовувати. Сподіваюсь, що ця стаття була корисною для вас. Якщо у вас є якісь питання або зауваження, звертайтесь на будь-який із зазначених контактів . Дякую за увагу!