你可能并没有真正理解 TS 中的 never 类型

913 阅读7分钟

我相信很多人看到这个 never 类型的时候都会有种疑惑,这个类型有什么用?

先来看看 TypeScript 中的定义:“返回 never 的函数必须存在无法达到的终点”。啥???好像解释了什么又好像什么都没说。既然无法到达终点,那返回这个 never 的类型,能用来干嘛呢?

1、使联合类型更安全

我知道你很急,但是你先别急,先来看个例子:

type MethodType = 'GET' | 'POST';

function request(method: MethodType) {
  switch(method){
    case 'GET':
      return '使用 GET 方法获取到的数据';
    case 'POST':
      return '使用 POST 方法获取到的数据';
  }
}

上述代码中,首先定义了一个 MethodType 联合类型,用于约束 request 方法中的 method 参数必须为 GETPOST,然后在 request 方法中通过 switch 分支判断传入的请求参数并返回对应的数据。好,到这里代码都没什么问题,能正常执行。

可能有些细心的小伙伴可能发现了:“你这里是不是少了个 default 分支?”其实呢,在这里加不加 default 分支都行,因为已经有 MethodType 类型约束了参数的类型只能是 GETPOST,按照这个类型逻辑来说是不可能进入 default 分支的。但是编程规范又告诉我们:“switch 语句中应当含有 default 分支”,好,那咱们就加上吧。于是,代码变成了下面这样:

type MethodType = 'GET' | 'POST';

function request(method: MethodType) {
  switch(method){
    case 'GET':
      return '使用 GET 方法获取到的数据';
    case 'POST':
      return '使用 POST 方法获取到的数据';
    default:
      return '返回默认数据';
  }
}

然后又有小伙伴要问了,你这跟 never 有什么关系呢?你先忍一忍,接着往下看,我们分别在这三个 case 分支中看看 method 的类型是什么:

  • GET 分支image.png
  • POST 分支image.png
  • default 分支image.png

可以看到,不同分支中 method 所对应的类型是不一致的。这得益于 TS 中的类型收窄功能,它会分析你的分支,根据分支中不同的条件来对类型做一个相应的收窄。例如上述示例中的 GET 分支,method 就被收窄成 GET 类型了,不再是 MethodType 这个联合类型了,其他分支同理。

接下来,重点讲讲 default 分支为啥是 never 类型。前面咱们也分析过按照正常逻辑是不可能进入 default 分支的,咋办? TS 总得搞个什么东西表示一下吧,好了,这个东西就是今天的主角 never,这也印证了前面开头的那句话 “返回 never 的函数必须存在无法达到的终点”,对应到这个例子中,request 函数无法达到的终点就是 default 分支。怎么样,是不是有一种豁然开朗的感觉!

到这里咱们已经理解了 never 所表达的意思,那它有什么具体用处呢?接着往下看。

前面不是说过 default 分支没啥用吗,那我们真就把它删了可以吗?目前是没啥问题,那假如后续由于需求的变更,需要对请求类型进行扩展,得加一个 DELETE 类型,代码如下:

type MethodType = 'GET' | 'POST' | 'DELETE';

function request(method: MethodType) {
  switch(method){
    case 'GET':
      return '使用 GET 方法获取到的数据';
    case 'POST':
      return '使用 POST 方法获取到的数据';
  }
}

这不就出问题了吗?request 函数并没有处理 DELETE 分支。当然,这里由于示例代码比较简单,你一眼就看出来了需要在 switch 语句中加上这个 DELETE 分支的处理逻辑。

但是,一般来说,使用 TS 的项目都是一些比较大型的项目,此时你并不知道加了这个 DELETE 类型会影响哪些地方,也不知道要修改哪些函数,而且很难一一找到使用这个类型的地方,关键是编译器也不会报错。这时候,bug 不就来了吗?那怎么避免这种情况的出现呢?嘿,never 就可以派上用场了!

咱们稍微改造一下前面的 default 分支:

type MethodType = 'GET' | 'POST';

function request(method: MethodType) {
  switch (method) {
    case 'GET':
      return '使用 GET 方法获取到的数据';
    case 'POST':
      return '使用 POST 方法获取到的数据';
    default:
      const m: never = method;
      return m + '返回默认数据';
  }
}

主要添加的逻辑是:在 default 分支中新建一个 never 类型的变量 m,并将 method 赋值给它,然后通过 return 返回出去。其实这里并不用关心返回的是啥,因为执行不到这个分支。可以看到此时代码并没啥问题,m 变量的类型与 method 的类型是匹配的,所以编译器也不报错。

image.png

好了,重点来了!!!下面咱们把新需求中的 DELETE 类型给加上去

type MethodType = 'GET' | 'POST' | 'DELETE';

function request(method: MethodType) {
  switch (method) {
    case 'GET':
      return '使用 GET 方法获取到的数据';
    case 'POST':
      return '使用 POST 方法获取到的数据';
    default:
      const m: never = method;
      return m + '返回默认数据';
  }
}

哦吼,编译器报错了,如下图:

image.png

为啥呢?因为根据 TS 的类型收窄功能,它知道在这个分支里面还有一种情况,那就是 method 的类型为 DELETE 时候。它是可达的,所以不能赋值给 never,导致编译器报错了。

image.png

这个时候咱们就可以在编码阶段非常清楚的知道,这个函数里面应该添加一个 DELETE 分支。假设引用这个类型的函数特别多,咱们也可以根据报错信息一一的进行修改,如果不修改,项目就运行不起来。这样的话,再也不用担心漏改导致发布之后出 bug 了。

怎么样,never 的用处是不是也理解了。那咱们再看个例子,了解下 never 的另一个用处。

2、排除特定的类型

假如你要定义一个这样的函数:该函数的参数不能为数字类型,但可以是其他的任何类型。意思就是得排除数字类型的参数。

咱们尝试的实现一下:

function call(param) {
  if (typeof param === 'number') {
    throw new Error('参数不能是数字类型')
  }
  return param
}

首先,这个函数功能是没有任何问题的。但是这个函数发生错误的时间被延迟到了运行时,也就意味着在编写代码的时候,收不到任何的错误提示。

image.png

那能不能利用 TS 的类型约束功能将发生错误的时间提升到编译阶段呢?答案是肯定的。

咱们来改造一下这个 call 函数:

function call<T>(param: T extends number ? never : T) {
  return param;
}

经过改造之后,这个函数将接收一个泛型 T,再给参数 param 的类型加了个条件判断:如果接收到的泛型 T 是 number 类型就给它赋值为 never 类型,否则直接使用这个泛型 T,这样一来就达到目的了。

当咱们给它传一个 number 类型参数的时候,由于类型约束的存在,T extends number 这个条件被满足,于是 param 参数就变成了一个 never 类型,而 never 类型是不能接收 number 类型参数的。此时,编译器就报错了。

image.png

而且传其他任何类型都是没有任何问题的:

image.png

咱们也可以让代码更优雅一点,把参数类型提出来单独定义:

// 排除数字类型
type ExcludeNumberType<T> = T extends number ? never : T;

function call<T>(param: ExcludeNumberType<T>) {
  return param;
}

或者让这个类型更通用一点,可以排除任何类型:

// 排除任何类型
type ExcludeType<T, E> = T extends E ? never : T;

function call<T>(param: ExcludeType<T, number>) {
  return param;
}

image.png

总结

通过本篇文章,咱们理解了 never 类型所表达的意思:返回 never 的函数必须存在无法达到的终点

以及它的两个实际用处:

  1. 使联合类型更安全
  2. 排除特定的类型

都看到这了,不妨来个一键三连(点赞 + 关注 + 收藏)?

欢迎大家在评论区留下宝贵的建议!


作者:HashTang