Полное руководство по типу Never в TypeScript
never
в TypeScript очень мало обсуждается, поскольку он не так распространён и не так неизбежен, как другие типы. Начинающий пользователь TypeScript, вероятно, может игнорировать тип never
, поскольку он появляется только при работе с расширенными типами, такими как условные типы, или при чтении их загадочных сообщений об ошибках типов.Тип never
имеет довольно много хороших вариантов использования в TypeScript. Однако у него есть и свои подводные камни
, с которыми необходимо быть осторожным.
В этой статье я расскажу о следующем:
- Значение типа
never
и зачем он нужен. - Практическое применение и подводные камни типа
never
. - Много каламбуров 🤣.
Что такое тип 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 невозможность
проявляется различными способами, а именно:
Пустой тип, не имеющий никакого значения, который может быть использован для представления следующего:
- Недопустимые параметры в дженериках и функциях.
- Пересечение несовместимых типов.
- Пустое объединение (объединение типа небытия).
Тип возврата функции, которая никогда (каламбурно) не возвращает управление вызывающей стороне по завершении выполнения, например,
process.exit
в Node- Не путать с
void
, так какvoid
означает, что функция не возвращает ничего полезного вызывающему.
- Не путать с
Ветвь else, которая никогда (каламбур... ладно, думаю, на сегодня хватит каламбуров) не должна вводиться в условие типа
Тип выполненного значения отклонённого промиса
const p = Promise.reject('foo') // const p: Promise<never>
Как never
работает с объединениями и пересечениями
Аналогично тому, как число ноль работает при сложении и умножении, тип never
обладает особыми свойствами при использовании в объединениях и пересечениях типов:
never
выпадает из объединения типов, аналогично тому, как если к числу прибавить ноль, то получится, то же самое число.- Например,
type Res = never | string // string
- Например,
never
перекрывает другие типы в пересечениях типов, подобно тому, как умножение числа на ноль даёт ноль.- Например,
type Res = never & string // 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 для оценки и получения результирующего типа:
Условные типы распределяются по объединениям типов (в данном случае это
Name
):type ExtractedType = ExtractTypeByName<All, Name>
⬇️
type ExtractedType = ExtractTypeByName<Foo | Bar, 'foo'>
⬇️
type ExtractedType = ExtractTypeByName<Foo, 'foo'> | ExtractTypeByName<Bar, 'foo'>Замена реализации и оценка по отдельности
type ExtractedType = Foo extends {name: 'foo'} ? Foo : never
| Bar extends {name: 'foo'} ? Bar : never
⬇️
type ExtractedType = Foo | neverУдаление
never
из объединенияtype ExtractedType = Foo | never
⬇️
type ExtractedType = Foo
Фильтрация ключей в сопоставленных типах
В 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
.
Чтобы обойти эту проблему, необходимо использовать утверждения типов (или перегрузки функций):
return Math.floor(Math.random() * 10) as ReturnTypeByInputType[T]
return Math.floor(Math.random() * 10) as 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 объяснил это в этом обсуждении. Все сводится к следующему:
- TypeScript автоматически распределяет объединённые типы в условных типах.
never
является пустым объединением.- Поэтому, когда происходит распределение, распределять уже нечего, поэтому условный тип снова разрешается в
never
.
Единственным обходным решением является отказ от неявного распределения и обёртывание параметра типа в кортеж:
type IsNever<T> = [T] extends [never] ? true : false;
type Res1 = IsNever<never> // 'true' ✅
type Res2 = IsNever<number> // 'false' ✅
На самом деле это напрямую вытекает из исходного кода TypeScript, и было бы неплохо, если бы TypeScript мог показать это извне.
В заключение
В статье мы рассмотрели достаточно много вопросов:
- Сначала мы поговорили об определении и назначении типа
never
. - Затем мы поговорили о различных вариантах его использования:
- наложение ограничений на функции за счёт использования того факта, что
never
является пустым типом - отсеивание ненужных членов объединения и свойств объектного типа
- помощь в анализе потока управления
- обозначение недопустимых или недостижимых условных ветвей
- наложение ограничений на функции за счёт использования того факта, что
- Мы также обсудили, почему
never
может неожиданно появляться в сообщениях об ошибках типов из-за неявного пересечения типов - Наконец, мы рассмотрели, как можно проверить, является ли тип действительно типом
never
.