likes
comments
collection
share

一篇 Typescript 的 Never 类型的完全指南

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

原文看这里 👉A Complete Guide To TypeScript’s Never Type

个人推荐 👉Making sense of TypeScript using set theory

文中的有些地方着实不好翻译,属于个人理解,欢迎予以宝贵意见。

  • 注释( annotation)=> 声明类型
  • 分型 (type) => 声明...类型
  • 判别式(Discriminant)=> 判别(判别对象类型归属)

Typescript 的 never 类型很少被提及,因为它不像其他类型那样普遍或必需。 Typescript 初学者可能会忽略 never 类型,因为它只在处理高级类型(例如条件类型 T extends U ? X : Y )或读取它们的隐含类型错误消息时出现。 never 类型在 TypeScript 中确实有一些的用例。然而,它也有自身的陷阱,这是你需要小心的。 在本文中,我将介绍:

  • never 类型的含义以及我们为什么需要它
  • never 类型的实际应用和陷阱
  • 还有一堆双关语 🤣

什么是 never 类型

想要完全理解 never 类型和它的目的,我们首先必须先理解什么是类型,以及它在类型系统中扮演什么角色。

类型就是可能值的集合。比如,string 类型代表了可能字符串的无限集合。所以,当我们用 string 类型注释变量时,这个变量只能具有该集合内的值,即字符串:

let foo: string = 'foo';
foo = 3; // ❌ number is not in the set of strings

在 TypeScript 中, never 是一组空值。事实上,在另一个流行的 JavaScript 类型系统 Flow 中,等效的类型被确切地称为空类型(empty)

由于集合中没有值,因此 永不 类型“永不”(双关语)能有任何值,包括 any 类型的值。这就是为什么 never 类型有时也被称为无人类型(uninhabitable type)底层类型(bottom type)

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

底层类型是TypeScript 操作手册对它的定义。我发现当我们将 never 放在类型层次结构树(type hierarchy tree)中时更有意义,这是我用来理解子类型(subtyping)的心智模型。

下个符合逻辑的问题是,我们为什么需要 never 类型?

为什么需要 never 类型

就像我们的数字系统中有零来表示无一样,我们需要一个类型来表示我们的类型系统中的不可能。

“不可能”这个词本身就含糊不清。在 TypeScript 中,“不可能”以各种方式表现出来,例如:

  • 一个不能有任何值的空类型,它可用于表示以下内容:
    • 泛型和函数中不允许(传入)的参数。
    • 不兼容的类型的交集。
    • 空联合(无的联合类型)。
  • 函数的返回类型,当它完成执行时“永不”(双关语)将控制权返回给调用者,例如 Node 中的 process.exit
    • 不要将它与 void 混淆,因为 void 意味着函数不会返回任何对调用者有用的东西。
  • 一个 else 分支“永不”(双关语......好吧,我认为今天的双关语已经足够了)应该输入进条件类型中
  • 一个被拒绝的 promise 的完成值的类型
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) {}

// it only accepts `never`
declare let myNever: never;
fn(myNever); // ✅

// passing anything else (or nothing) causes a type error
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'.

// cannot even pass `any`
declare let myAny: any;
fn(myAny); // ❌ Argument of type 'any' is not assignable to parameter of type '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); // Argument of type 'string' is not assignable to parameter of type 'never'
    }
}

部分禁用结构类型

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

我们可以将联合类型 VariantA | VariantB 用于参数。但是,由于 TypeScript 中的类型兼容性基于结构子类型(structural subtyping),因此允许将属性多于参数类型的对象类型传递给函数(除非传递对象字面值):

type VariantA = {
    a: string;
};

type VariantB = {
    b: number;
};

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

const input = { a: 'foo', b: 123 };
fn(input); // TypeScript doesn't complain but this shouldn't be allowed for our use case

上面的代码片段没有给我们提示 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); // ✅ allowed

现在,出于某些原因,我们想要一个只读的缓存对象并且只允许通过 get 方法读取数据。我们可以将 put 方法的传入参数声明为 never 类型,这样它就不能接受传入的任何值:

declare class ReadOnlyCache<R> extends MyCache<never, R> {}
// Now type parameter `T` inside MyCache becomes `never`

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

备注:这可能不是派生类的好用例,我不是面向对象编程的真正专家,所以请自行辨别。

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

当使用 infer 在条件类型中创建额外的类型变量时,我们必须为每个 infer 关键字添加一个 else 分支:

type A = 'foo';
type B = A extends infer C
    ? C extends 'foo'
        ? true
        : false // inside this expression, C represents A
    : never; // this branch is unreachable but we cannot omit it

// 👨‍🏫小课堂:
// 条件类型 T extends U? X: Y
// 类型推断 infer,获取U类型某个部分的类型(如:类型C)
// 获取数组元素类型
type InferArray<T> = T extends (infer C)[] ? C : never;
const item: InferArray<number[]> = 1; // item is number type
为什么这个 extends infer 有用 ? 在我之前的帖子中,我提到了如何创建声明 “局部类型变量” 以及 extends infer 。如果您还没有看到,请在此处查看。

过滤联合类型中部分成员

除了表示不可能的条件分支外,never 还可以用来过滤掉条件类型中不需要的类型.

正如我们之前讨论过的,当用作联合成员时, never 类型会被自动移除。换句话说,never 类型在联合类型中是无用的。

当我们编写工具类型,以便根据特定条件从联合类型中选择联合成员时,never 类型在联合类型中的无用性使其成为放置在 else 分支中的完美类型。

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'>; // the result type is Foo
详细了解其工作原理 这是 TypeScript 计算获取结果类型的步骤列表:
  1. 分布在联合类型上的条件类型(即本例中的 Name ):
    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 中,类型是不可变的。如果我们想从一个对象类型中删除一个属性,我们必须通过转换和过滤当前对象类型的,从而创建一个新的对象类型。当我们条件性地将映射类型中的键重新映射为 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 类型时,这意味着该函数在完成执行后永远不会将控制权返回给调用者。我们可以利用它来帮助控制流分析收窄类型。

一个函数永远没有返回有几个原因:它可能在所有代码路径上抛出异常,可能陷入死循环,或者从程序中退出,例如 Node 中的 process.exit

在下面的代码片段中,我们使用一个返回 never 类型的函数来从 foo 的联合类型中移除 undefined:

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

let foo: string | undefined;

if (!foo) {
    throwError();
}

foo; // strings

或者在 ||?? 操作之后调用 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 ,因为布尔值是一个判别属性( true | false 的并集)。
type Foo = {
    name: boolean,
    age: number
}
type Bar = {
    name: number,
    age: number
}
type Baz = Foo & Bar // never
查看这个 PR 学习更多

如何(从报错信息)读懂 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') {
        // 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] 来检索相应的返回类型。

然而,对于每个返回语句,我们都有一个类型错误,即:类型 X 不可分配给类型“never”,其中 X 是字符串、数字或布尔值,取决于条件分支。

这就是 TypeScript 试图帮助我们缩小程序中出现异常状态的可能性的地方:每个返回值都应该分配给 ReturnTypeByInputType[T] 类型(正如我们在示例中所声明的那样),其中 ReturnTypeByInput type[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 比它应当是 never 更难。

考虑以下代码片段:

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

type Res = IsNever<never>; // never 🧐

Restrue 还是 false?您可能会感到惊讶,答案是否定的:Res 实际上是 never。事实上, 当我第一次遇到这个时,它确实让我感到沮丧了。 Ryan Cavanaugh 在本期中解释了这个问题,归结为:

  • TypeScript 自动在条件类型中分配联合类型 (代码中的 T)
  • never是一个空的联合类型
  • 因此,当分配发生时,没有什么可分配的,所以条件类型再次解析为 never

唯一的解决方法是选择退出隐式分发并将类型参数包装在元组中:

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

// 👨‍🏫小课堂:
// 同理实现联合类型限制分配
type Normal<A, B> = A extends B ? A : false;
type Strict<A, B> = [A] extends [B] ? A : false;
type a = Normal<1 | 2 | 3, 1>; // false | 1
type a1 = Strict<1 | 2 | 3, 1>; // false

这实际上直接来自 TypeScript 的源代码,如果 TypeScript 可以在外部公开它就好了。

总结

我们在这篇博客文章中涵盖了很多内容:

  • 首先,我们讨论了 never 类型的定义和用途。
  • 然后,我们讨论了它的各种用例:
    • 利用 never 是空类型这一事实对函数施加限制
    • 过滤掉不需要的联合成员和对象类型的属性
    • 辅助控制流分析
    • 表示无效或无法到达的条件分支
  • 我们还讨论了为什么由于隐式类型交集,never 会在类型错误消息中意外出现
  • 最后,我们介绍了如何检查一个类型是否真的是 never 类型
转载自:https://juejin.cn/post/7201048368389914682
评论
请登录