Полное руководство по типу Never в TypeScript

Источник: «A Complete Guide To TypeScript's Never Type»
Тип never в TypeScript очень мало обсуждается, поскольку он не так распространён и не так неизбежен, как другие типы. Начинающий пользователь TypeScript, вероятно, может игнорировать тип never, поскольку он появляется только при работе с расширенными типами, такими как условные типы, или при чтении их загадочных сообщений об ошибках типов.

Тип never имеет довольно много хороших вариантов использования в TypeScript. Однако у него есть и свои подводные камни, с которыми необходимо быть осторожным.

В этой статье я расскажу о следующем:

Что такое тип never

Чтобы полностью понять, что такое тип never и в чем его назначение, необходимо сначала понять, что это за тип и какую роль он играет в системе типов.

Тип — это набор возможных значений. Например, тип string представляет собой бесконечное множество возможных строк. Поэтому, когда мы аннотируем переменную с типом string, такая переменная может иметь значения только из этого множества, т.е. строки:

let foo: string = 'foo'
foo = 3 // ❌ number не входит в набор строк

В TypeScript never — это пустой набор значений. Фактически, в Flow, другой популярной системе типов JavaScript, эквивалентный тип называется именно empty

Поскольку в наборе нет значений, тип never никогда (каламбурно) не может иметь никакого значения, включая значения типа any. Поэтому never также иногда называют uninhabitable type или bottom type.

declare const any: any
const never: never = any // ❌ Тип 'any' не может быть присвоен типу 'never'

Bottom type — это то, как его определяет TypeScript Handbook. Я обнаружил, что это имеет больше смысла, когда мы помещаем never в дерево иерархии типов — ментальную модель, которую я использую для понимания подтипизации.

Следующий логичный вопрос — зачем нам вообще нужен тип never?

Зачем нам нужен тип never

Подобно тому, как в системе счисления у нас есть ноль для обозначения количества ничего, в системе типов нам нужен тип для обозначения невозможности.

Само слово невозможность является расплывчатым. В TypeScript невозможность проявляется различными способами, а именно:

Как never работает с объединениями и пересечениями

Аналогично тому, как число ноль работает при сложении и умножении, тип never обладает особыми свойствами при использовании в объединениях и пересечениях типов:

Эти два поведения/характеристики типа never закладывают основу для некоторых наиболее важных вариантов его использования, которые мы рассмотрим далее.

Как использовать тип never

Хотя вы, вероятно, нечасто будете использовать never, существует достаточно много оправданных случаев его применения:

Аннотирование недопустимых параметров функций для наложения ограничений

Поскольку мы никогда не можем присвоить значение типу never, мы можем использовать его для наложения ограничений на функции для различных случаев использования.

Обеспечение исчерпывающего соответствия в операторах switch и if-else

Если функция может принимать только один аргумент типа never, то эта функция никогда не может быть вызвана ни с каким значением, отличным от never (без того, чтобы на нас не накричал компилятор TypeScript):

function fn(input: never) {}

// принимает только 'never'
declare let myNever: never
fn(myNever) // ✅

// Передача чего-либо другого (или ничего) приводит к ошибке типа
fn() // ❌ Не предоставлен аргумент 'input'.
fn(1) // ❌ Аргумент типа 'number' не может быть присвоен параметру типа 'never'.
fn('foo') // ❌ Аргумент типа 'string' не может быть присвоен параметру типа 'never'.

// нельзя передать даже `any`.
declare let myAny: any
fn(myAny) // ❌ Аргумент типа 'any' не может быть присвоен параметру типа 'never'.

Мы можем использовать такую функцию для обеспечения исчерпывающего соответствия в операторах switch и if-else: используя её как случай по умолчанию, мы гарантируем, что все случаи будут покрыты, поскольку то, что останется, должно быть типа never. Если мы случайно опустим возможное соответствие, то получим ошибку типа. Например:

function unknownColor(x: never): never {
throw new Error("unknown color");
}


type Color = 'red' | 'green' | 'blue'

function getColorName(c: Color): string {
switch(c) {
case 'red':
return 'is red';
case 'green':
return 'is green';
default:
return unknownColor(c); // Аргумент типа 'string' не может быть присвоен параметру типа 'never'
}
}

Частичный запрет структурной типизации

Допустим, у нас есть функция, которая принимает параметр либо типа VariantA, либо VariantB. Но пользователь не должен передавать тип, включающий в себя все свойства обоих типов, т.е. подтип обоих типов.

Для параметра мы можем использовать объединение типа VariantA | VariantB. Однако поскольку совместимость типов в TypeScript основана на структурной подтипизации, передача в функцию объектного типа, имеющего больше свойств, чем тип параметра, разрешена (если только не передавать объектные литералы):

type VariantA = {
a: string,
}

type VariantB = {
b: number,
}

declare function fn(arg: VariantA | VariantB): void


const input = {a: 'foo', b: 123 }
fn(input) // TypeScript не жалуется, но для нашего случая это должно быть недопустимо

Приведённый фрагмент кода не приводит к ошибке типа в TypeScript.

Используя never, мы можем частично отключить структурную типизацию и не позволить пользователям передавать значения объектов, включающие оба свойства:

type VariantA = {
a: string
b?: never
}

type VariantB = {
b: number
a?: never
}

declare function fn(arg: VariantA | VariantB): void


const input = {a: 'foo', b: 123 }
fn(input) // ❌ Типы свойства 'a' несовместимы

Предотвращение непреднамеренного использования API

Допустим, мы хотим создать экземпляр Cache, чтобы читать и сохранять данные из/в него:

type Read = {}
type Write = {}
declare const toWrite: Write

declare class MyCache<T, R> {
put(val: T): boolean;
get(): R;
}

const cache = new MyCache<Write, Read>()
cache.put(toWrite) // ✅ разрешено

Теперь, по некоторым причинам, мы хотим иметь кэш, доступный только для чтения и позволяющий считывать данные только через метод get. Мы можем указать аргумент метода put как never, чтобы он не мог принять никакое переданное ему значение:

declare class ReadOnlyCache<R> extends MyCache<never, R> {}
// Теперь параметр типа `T` внутри MyCache становится `never`.

const readonlyCache = new ReadOnlyCache<Read>()
readonlyCache.put(data) // ❌ Аргумент типа 'Data' не может быть присвоен параметру типа 'never'.

Не касаясь типа never, замечу, что это может быть не совсем удачным примером использования производных классов. Я не являюсь экспертом в области объектно-ориентированного программирования, поэтому пользуйтесь собственным мнением.

Обозначим теоретически недостижимые условные ветви

При использовании infer для создания дополнительной переменной типа внутри условного типа необходимо добавить ветвь else для каждого ключевого слова infer:

type A = 'foo';
type B = A extends infer C ? (
C extends 'foo' ? true : false// В этом выражении C представляет A
) : never // эта ветка недостижима, но мы не можем её опустить

Чем полезна эта комбинация extends infer?

В предыдущей заметке я рассказывал о том, как с помощью extends infer можно создать декларацию локальной (типовой) переменной. Если вы ещё не видели, посмотрите здесь.

Отфильтровать элементы объединения из объединения типов

Помимо обозначения невозможных ветвей, never может использоваться для отсеивания нежелательных типов в условных типах.

Как мы уже говорили, при использовании в качестве элемента объединения тип never удаляется автоматически. Другими словами, тип never бесполезен в объединении типов.

Когда мы пишем утилиту для выбора элементов объединения из объединения типа по определённым критериям, бесполезность типа never в объединениях типов делает его идеальным типом для размещения в ветвях else.

Допустим, нам нужна утилита типа ExtractTypeByName для извлечения элементов объединения, свойство name которых является строковым литералом foo, и отсеивания тех, которые не совпадают:

type Foo = {
name: 'foo'
id: number
}

type Bar = {
name: 'bar'
id: number
}

type All = Foo | Bar

type ExtractTypeByName<T, G> = T extends {name: G} ? T : never

type ExtractedType = ExtractTypeByName<All, 'foo'> // тип результата - Foo

Посмотрите, как это работает в деталях:

Далее приведён список шагов, выполняемых TypeScript для оценки и получения результирующего типа:

Фильтрация ключей в сопоставленных типах

В TypeScript типы являются неизменяемыми. Если мы хотим удалить свойство из объектного типа, мы должны создать новый тип путём преобразования и фильтрации существующего. Когда мы условно переставляем ключи в сопоставленных типах на never, эти ключи отфильтровываются.

Приведём пример типа Filter, который отфильтровывает свойства объектного типа на основе их типов значений.

type Filter<Obj extends Object, ValueType> = {
[Key in keyof Obj
as ValueType extends Obj[Key] ? Key : never]
: Obj[Key]
}



interface Foo {
name: string;
id: number;
}


type Filtered = Filter<Foo, string>; // {name: string;}

Сужение типов при анализе потока управления

Если мы указываем возвращаемое значение функции как never, это означает, что функция никогда не возвращает управление вызывающей стороне после завершения её выполнения. Мы можем использовать это для анализа потока управления, чтобы сузить круг типов.

Функция может возвращать never по нескольким причинам: она может выбросить исключение на всех путях кода, она может зациклиться навсегда или выйти из программы, например, process.exit в Node.

В следующем фрагменте кода мы используем функцию, возвращающую тип never, чтобы убрать undefined из объединённого типа для foo:

function throwError(): never {
throw new Error();
}

let foo: string | undefined;

if (!foo) {
throwError();
}

foo; // string

Или вызвать throwError после оператора || или ??.

let foo: string | undefined;

const guaranteedFoo = foo ?? throwError(); // string

Обозначаем невозможные пересечения несовместимых типов

Этот случай может показаться скорее поведением/характеристикой языка TypeScript, чем практическим применением never. Тем не менее это очень важно для понимания некоторых загадочных сообщений об ошибках, с которыми вы можете столкнуться.

Пересекая несовместимые типы, можно получить тип never.

type Res = number & string // never

А тип never можно получить, пересекая любые типы с never.

type Res = number & never // never

Для объектных типов все сложнее…

При пересечении типов объектов, в зависимости от того, рассматриваются ли непересекающиеся свойства как дискриминантные (в основном это литеральные типы или объединения литеральных типов), можно получить или не получить весь тип, сведённый к never.

В данном примере только свойство name становится never, поскольку string и number не являются дискриминантными свойствами

type Foo = {
name: string,
age: number
}
type Bar = {
name: number,
age: number
}

type Baz = Foo & Bar // {name: never, age: number}

В следующем примере весь тип Baz сводится к never, поскольку boolean является дискриминантным свойством (объединение true | false)

type Foo = {
name: boolean,
age: number
}

type Bar = {
name: number,
age: number
}

type Baz = Foo & Bar // never

Ознакомьтесь с этим запросом Push Request Reduce intersections by discriminants #36696, чтобы узнать больше.

Как прочитать тип never (из сообщений об ошибках)

Возможно, вы получали сообщения об ошибках, связанных с неожиданным типом never, из кода, который вы не аннотировали never явно. Обычно это происходит потому, что компилятор TypeScript пересекает типы. Он делает это неявно для того, чтобы сохранить безопасность типов и обеспечить их корректность.

Вот пример (поиграйте с ним в TypeScript playground), который я использовал в своей предыдущей статье в блоге о типизации полиморфных функций:

type ReturnTypeByInputType = {
int: number
char: string
bool: boolean
}

function getRandom<T extends 'char' | 'int' | 'bool'>(
str: T
): ReturnTypeByInputType[T] {
if (str === 'int') {
// генерируем случайное число
return Math.floor(Math.random() * 10) // ❌ Тип 'number' не может быть присвоен типу 'never'.
} else if (str === 'char') {
// генерируем случайный символ
return String.fromCharCode(
97 + Math.floor(Math.random() * 26) // ❌ Тип 'string' не может быть присвоен типу 'never'.
)
} else {
// генерируем случайное логическое значение
return Boolean(Math.round(Math.random())) // ❌ Тип 'boolean' не может быть присвоен типу 'never'.
}
}

В зависимости от типа передаваемого аргумента функция возвращает либо число, либо строку, либо логическое значение. Для получения соответствующего возвращаемого типа мы используем индексный доступ ReturnTypeByInputType[T].

Однако для каждого выражения return мы получаем ошибку типа, а именно: Type X is not assignable to type 'never', где X — строка, число или логическое значение, в зависимости от ветви.

Именно здесь TypeScript пытается помочь нам уменьшить вероятность возникновения проблемных состояний в нашей программе: каждому возвращаемому значению должен быть присвоен тип ReturnTypeByInputType[T] (как мы аннотировали в примере), где ReturnTypeByInputType[T] во время выполнения может оказаться либо числом, либо строкой, либо логическим значением.

Типовая безопасность может быть достигнута только в том случае, если мы убедимся, что возвращаемый тип может быть присвоен всем возможным ReturnTypeByInputType[T], т.е. пересечению чисел, строк и логических значений. А что такое пересечение этих трёх типов? Точно never, поскольку они несовместимы друг с другом. Поэтому в сообщениях об ошибках мы видим never.

Чтобы обойти эту проблему, необходимо использовать утверждения типов (или перегрузки функций):

Может быть, другой, более очевидный пример:

function f1(obj: { a: number, b: string }, key: 'a' | 'b') {
obj[key] = 1; // Тип 'number' не может быть присвоен типу 'never'.
obj[key] = 'x'; // Тип 'string' не может быть присвоен типу 'never'.
}

obj[key] может оказаться либо строкой, либо числом в зависимости от значения key во время выполнения программы. Поэтому TypeScript добавил это ограничение, т.е. любые значения, которые мы записываем в obj[key], должны быть совместимы с обоими типами, string и number, просто, чтобы быть в безопасности. Таким образом, он пересекает оба типа и даёт нам тип never.

Как проверить соответствие типу never

Проверять, является ли тип never, сложнее, чем следовало бы.

Рассмотрим следующий фрагмент кода:

type IsNever<T> = T extends never ? true : false

type Res = IsNever<never> // never 🧐

Является ли Res true или false? Вас может удивить, что ответ — ни то, ни другое: Res на самом деле never. Фактически.

Когда я столкнулся с этим в первый раз, это определённо выбило меня из колеи. Ryan Cavanaugh объяснил это в этом обсуждении. Все сводится к следующему:

Единственным обходным решением является отказ от неявного распределения и обёртывание параметра типа в кортеж:

type IsNever<T> = [T] extends [never] ? true : false;
type Res1 = IsNever<never> // 'true' ✅
type Res2 = IsNever<number> // 'false' ✅

На самом деле это напрямую вытекает из исходного кода TypeScript, и было бы неплохо, если бы TypeScript мог показать это извне.

В заключение

В статье мы рассмотрели достаточно много вопросов:

Дополнительные материалы

Предыдущая Статья

Размещение NULL значений для ORDER BY с nullable столбцами

Следующая Статья

Понимание Value Objects/Объектов Значения в PHP