likes
comments
collection
share

Typescript never 类型的完全指南

作者站长头像
站长
· 阅读数 18

英文原文:www.zhenghao.io/posts/ts-ne… (作者已授权)

TypeScript 的never类型很少被讨论,因为它不像其他类型那样常用。TypeScript 初学者可能会忽略never类型,因为它只在处理高级类型(例如条件类型)或碰到一些神秘的类型错误时出现。

事实上never类型在 TypeScript 中确实有很多不错的用例。但是,它也有您需要注意的陷阱。

在这篇博文中,博主将介绍:

  • 类型never的含义以及我们为什么需要它。
  • never实际的一些应用和陷阱。
  • 以及很多双关🤣

什么不是类型

要完全理解never类型及其用途,我们必须首先了解什么类型,以及never在类型系统中扮演了什么角色。

首先,类型是一组集合(Set)的子集。例如,string 类型表示无限多个可能存在的字符串子集。因此,当我们用 string 类型来标注一个变量的时候,这个变量只应该是该集合内的子集,即字符串:

let foo: string = 'foo'
foo = 3 // ❌ 数字不属于字符串的子集

在 TypeScript 中,never 本质就是一个空集。事实上,在另一个流行的 JavaScript 类型系统 Flow 中,作用完全一样的类型直接被命名为 empty

由于集合中没有值,never 类型字面意义地永远不会(双关)有任何值,包括 any 类型的也不在 never 这个空集中。这就是为什么 never 有时也被称为 uninhabited 类型或 Bottom 类型。

declare const any: any
const never: never = any // ❌ type 'any' is not assignable to type 'never'

Bottom 类型是 TypeScript 手册 对它的定义。当我们把 never 放一个完整类型层次的树形结构图之后,看起来才比较有意义(这也帮助博主理解 Subtyping 的心智模型)。

Typescript never 类型的完全指南

OK,那么下一个合理的问题是,为什么我们需要 never 类型?

为什么我们需要 never 类型

就像在数学中我们使用来表示没有的数量一样,我们需要一个类型来表示类型系统中的不可能

“不可能”这个概念本存在一些模块的地方。在 TypeScript 中,“不可能”以各种方式表现出来,如:

  • 一个不能有任何值的空类型来表示以下内容:

    • 泛型和函数中不允许的参数。
    • 不兼容类型的交集。
    • 空的联合类型(啥都没有的联合类型)。
  • 函数的返回类型,当函数运行之后从不(双关)return。例如 Node.js 的 process.exit

    • 不要把这里跟 void 混淆了,因为 void 意味着函数会 return 一个空。
  • 在一个条件类型中一个 else 分支永远不(双关......好吧,博主认为今天的双关已经够了)应该被走到。

  • 被拒绝的 promise 类型

    const p = Promise.reject('foo') // const p: Promise<never>
    

never 如何与联合 & 交叉类型一起工作

类似于数字零在加法和乘法中的工作原理,never 在联合类型和交叉类型中使用时具有特殊属性:

  • never 从联合类型中删除,类似于 0 + n = n。

    • 例如 type Res = never | string // string
  • never 覆盖了交叉类型中的其他类型,类似于 0 * n = 0。

    • 例如 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() // ❌  An argument for 'input' was not provided.
fn(1) // ❌ Argument of type 'number' is not assignable to parameter of type 'never'.
fn('foo') // ❌ Argument of type 'string' is not assignable to parameter of type 'never'.

// 甚至 `any` 也不行
declare let myAny: any
fn(myAny) // ❌ Argument of type 'any' is not assignable to parameter of type 'never'.

我们可以通过在默认情况中调用这种函数来确保函数里的 switch 和 if-else 被完整匹配。由于余下的分支要匹配  never 类型,如果我们不小心遗传错了,TypeScript 就会报出类型错误。例如:

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); // Argument of type 'string' is not assignable to parameter of type 'never'
    }
}

译注:上面的例子中,虽然我们定义了 Color 类型中存在 'blue' 但是 getColorName 不支持,于是当我们调用到 getColorName('blue') 的时候写代码的时候以及运行过程中,这里都会报错。

部分禁止结构类型

假设我们有一个函数接受类型为 VariantAVariantB 的参数。但是,用户不能传包含两种类型的所有属性的类型,即只能传两种类型的子类型

我们可以利用联合类型 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) // ❌ Types of property 'a' are incompatible

防止意外的 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) // ✅ 允许

现在,当我们想要一个只读的缓存 Map,只允许通过 get 方法读取数据。我们就可以把它的 put 方法参数改为 never 类型,这样它就不能接受任何传入的值(译注:除非像上文一样手动整个 never):

declare class ReadOnlyCache<R> extends MyCache<never, R> {} 
                        // 现在泛型 T 变成了 `never`(put 传入的参数就是 T)

const readonlyCache = new ReadOnlyCache<Read>()
readonlyCache.put(data) // ❌ Argument of type 'Data' is not assignable to parameter of type 'never'.

PS: 这可能不是最好的派生用例,欢迎提供更好的方案。

表示理论上无法到达的分支

当用于 infer 时,我们必须为每个 infer 关键字添加一个 else 分支:

type A = 'foo';
type B = A extends infer C ? (
    C extends 'foo' ? true : false // 仅在这个()表达式里, C 是一个局部 A(而不是直接使用了全局的 A)
) : never // 不应该走到这里,但是我们不得不用 never 占位

更多信息参考 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'> // ExtractedType 计算结果为 Foo

TypeScript 评估和获取 ExtractedType 类型的步骤:

  1. 把 ExtractTypeByName 的参数展开后的得到:

    type ExtractedType = ExtractTypeByName<All, Name> 
    ⬇️                    
    type ExtractedType = ExtractTypeByName<Foo | Bar, 'foo'>
    ⬇️    
    type ExtractedType = ExtractTypeByName<Foo, 'foo'> | ExtractTypeByName<Bar, 'foo'>
    
  2. 分别代入执行后得到:

    type ExtractedType = Foo extends {name: 'foo'} ? Foo : never 
                        | Bar extends {name: 'foo'} ? Bar : never
    ⬇️
    type ExtractedType = Foo | never
    
  3. 所以 never 被约掉了

    type ExtractedType = Foo | never
    ⬇️
    type ExtractedType = Foo
    

过滤映射类型

TypeScript 中的类型是不可变的。如果我们想从一个对象类型中删除一个属性,我们必须通过转换和过滤现有的类型来创建一个新的。当我们有条件地将映射类型中的key 重新映射never 时,这些 key 就会被过滤掉。

下面是一个根据值类型过滤掉对象类型属性 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;}

控制流程分析中的窄类型(Narrow types)

当我们将 never 作为函数的返回值,这意味着该函数在被调用后永远不会将控制权返回给调用者。我们可以利用它来帮助控制流分析以缩小类型。

一个函数永远不会 return 有几个可能得原因:在代码路径上抛异常、不会终止的循环,或者它从应用程序终止了,例如 Node.js 中的 process.exit

在下例中,我们使用一个返回 never 类型的函数来剥离 foo 类型身上的 undefined

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 取交集

type Res = number & 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}  

在下面的例子中则是整个 Bar 类型被简化为,never因为布尔值是一个判别属性(一个并集true | false

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

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

type Baz = Foo & Bar // never

查看此 PR 以了解更多信息。

如何从错误消息中读取 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') {
    // generate a random number
    return Math.floor(Math.random() * 10) // ❌ Type 'number' is not assignable to type 'never'.
  } else if (str === 'char') {
    // generate a random char
    return String.fromCharCode(
      97 + Math.floor(Math.random() * 26) // ❌ Type 'string' is not assignable to type 'never'.
    )
  } else {
    // generate a random boolean
    return Boolean(Math.round(Math.random())) // ❌ Type 'boolean' is not assignable to type 'never'.
  }
}

该函数根据我们传递的参数类型返回数字、字符串或布尔值。我们使用索引访问ReturnTypeByInputType[T]来检索相应的返回类型。

然而,对于每个 return 语句,我们都有一个类型错误,即:Type X is not assignable to type 'never' 其中的 X 是 string、number 或者 boolean,具体取决于代码分支。

这就是 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;    // Type 'number' is not assignable to type 'never'.
    obj[key] = 'x';  // Type 'string' is not assignable to type 'never'.
}

obj[key] 最终可能是字符串或数字,具体取决于key运行时的值。因此,TypeScript 增加了这个约束,即我们写入的任何值都 obj[key] 必须兼容字符串和数字这两种类型,以确保安全。因此,它取了两种类型的交集即 never 类型。

如何检查 never

检查一个类型是否为 never 比我们想象的更难。

可以考虑以下代码片段:

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

type Res = IsNever<never> // never 🧐

Res 不是 truefalse,而是 never。 实际上,

当我第一次遇到这个时,感觉特别困惑。Ryan Cavanaughissue 中解释了这一点。归结为:

  • 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 类型。