【TypeScript】never 和 unknown 的优雅之道

发表于 2年以前  | 总阅读数:331 次

1、前言

TypeScript 在版本 2.0 和 3.0 分别引入了 “never” 和 “unknown” 两个基本类型,在引入这两个类型之后,TypeScript 的类型系统得到了极大的完善。

但在我平时接手代码的时候,我发现很多同学的观念还停留在 1.0 的时代,那个 any 大法好的时代。毕竟 JavaScript 是一门弱类型动态语言,我们以往不会投入过多的时间去关注类型设计。在引入 TypeScript 之后,我们甚至还会抱怨:“这代码怎么还越写越多了?”。

其实我们应该反过来思考,OOP 的编程范式,才是 ES6 后的代码应该有的模样。

2、TypeScript 中的 top type、bottom type

在类型系统设计中,有两种特别的类型:

  • Top type:被称为通用父类型,也就是能够包含所有值的类型。
  • Bottom type:代表没有值的类型,它也被称为类型,是所有类型的子类型。

按照类型系统的解释,在 TypeScript 3.0 中,有两个 top type(any 和 unknown) 和一个 bottom type(never)。

但也有一些人认为,any 也是一个 bottom type,因为 any 也可以作为很多类型的子类型。但这种说法其实并不严格,我们可以深入了解一下 unknown、any、never 这三个类型。

3、unknown 和 any

3.1 unknown —— 代表万物

我在阅读同事的代码时,很少看到 unknown 类型的出现。这并不意味着它不重要,相反,它是安全版本的 any 类型。

它和 any 的区别很简单,参考下面的 例子 :

function format1(value: any) {
    value.toFixed(2); // 不飘红,想干什么干什么,very dangerous
}

function format2(value: unknown) {
    value.toFixed(2); // 代码会飘红,阻止你这么做

    // 你需要收窄类型范围,例如:

    // 1、类型断言 —— 不飘红,但执行时可能错误
    (value as Number).toFixed(2);

    // 2、类型守卫 —— 不飘红,且确保正常执行
    if (typeof value === 'number') {
        // 推断出类型: number
        value.toFixed(2);
    }

    // 3、类型断言函数,抛出错误 —— 不飘红,且确保正常执行
    assertIsNumber(value);
    value.toFixed(2);
}


/** 类型断言函数,抛出错误 */
function assertIsNumber(arg: unknown): asserts arg is Number {
    if (!(arg instanceof Number)) {
        thrownewTypeError('Not a Number: ' + arg);
    }
}

使用 any 好比鬼屋探险,代码执行的时候处处见鬼。而 unknown 结合类型守卫等方式,可以确保上游数据结构不确定时,也能让代码正常执行。

3.2 any —— 一丝不挂

我们用到 any,就意味着放弃类型检查了,因为它不是用来描述具体类型的。

在使用它之前,我们需要想两件事:

  1. 能否使用更具体的类型
  2. 能否使用 unknown 代替

都不能的情况下,any 才是最后的选择。

3.3 回顾以前的类型设计

现有的一些类型设计用到了 any,其实不够准确。这里举两个例子:

3.3.1 String()

String() 能够接受任何参数,转化为字符串。

结合上文介绍的 unknown 类型,其实这里的参数也可以设计成 unknown,但内部实现就需要多设计些类型守卫了。但 unknown 类型是后面才出现的,所以一开始的设计还是采用了 any,也就是我们现在看到的:

/**
 * typescript/lib/lib.es5.d.ts
 */
interface StringConstructor {
    new(value?: any): String;
    (value?: any): string;
    readonly prototype: String;
    fromCharCode(...codes: number[]): string;
}

3.3.2 JSON.parse()

最近我写了一段涉及深拷贝的代码:

exportfunction deleteCommentFromComments<T>(comments: GenericsComment<T>[], comment: GenericsComment<T>) {
  // 深拷贝
  const list: GenericsComment<T>[] = JSON.parse(JSON.stringify(comments));

  // 找到对应的评论下标
  const targetIndex = list.findIndex((item) => {
    if (item.comment_id === comment.comment_id) {
      returntrue;
    }
    returnfalse;
  });

  if (targetIndex !== -1) {
    // 剔除对应的评论
    list.splice(targetIndex, 1);
  }

  return list;
}

很明显,JSON.parse () 的输出是随着输入动态改变的(甚至有可能抛出 Error),它的函数签名被设计成了:

interface JSON {
    parse(text: string, reviver?: (this: any, key: string, value: any) =>any): any;
    ...
}

这里可以用 unknown 嘛?可以,不过原因和上面一样,JSON.parse() 的函数签名被添加到 TypeScript 系统之前,unknown 类型还没出现,否则它的返回类型应该是 unknown。

4、never

上文提到,never 类型表示的是空类型,也就是值永不存在的类型。

值会永不存在的两种情况:

  1. 如果一个函数执行时抛出了异常,那么这个函数永远不存在返回值(因为抛出异常会直接中断程序运行,这使得程序运行不到返回值那一步,即具有不可达的终点,也就永不存在返回了);
  2. 函数中执行无限循环的代码(死循环),使得程序永远无法运行到函数返回值那一步,永不存在返回。
// 异常
function err(msg: string): never { // OK
  throw new Error(msg); 
}

// 死循环
function loopForever(): never { // OK
  while (true) {};
}

4.1 唯一的 bottom type

由于 never 是 typescript 的唯一一个 bottom type,它能够表示任何类型的子类型,所以能够赋值给任何类型:

let err: never;
let num: number = 4;

num = err; // OK

我们可以使用集合来理解 never,unknown 是全集,never 是最小单元(空集),任意类型都包含了 never。

4.1.1 null/undefined 和 never

这里可能就要问了,null 和 undefined 好像也可以表示任何类型的子类型,为啥不是 bottom type。非也,never 特殊就特殊在,除了自身以外,没有任何类型是它的子类型,或者说可以赋值给它。它才是人下人(狗头),我们可以用下面的 例子 对比看看:

// null 和 undefined,可以被 never 赋值
declare const n: never;

let a: null = n; // 正确
let b: undefined = n; // 正确

// never 是 bottom type,除了自己以外没有任何类型可以赋值给它
let ne: never;

ne = null; // 错误
ne = undefined; // 错误

declare const an: any;
ne = an; // 错误,any 也不可以

declareconst nev: never;
ne = nev; // 正确,只有 never 可以赋值给 never

上面的例子基本上说明了 null/undefined 跟 never 的区别,never 才是最 bottom 的。

4.1.2 为什么说 any 不是严格的 bottom type

我在阅读一些文章的时候发现,大家常说 any 既是 top type,也是 bottom type,但这种说法并不严谨。

从上文我们知道,除了 never 自身,没有任何类型能赋值给 never。any 是否满足这个特性呢?显然不能,举个很简单的例子:

const a = 'anything';

const b: any = a; // 能够赋值
const c: never = a; // 报错,不能赋值

而我们为什么说 never 才是 bottom type?维基百科上这样解释:

A function whose return type is bottom (presumably) cannot return any value, not even the zero size unit type. Therefore a function whose return type is the bottom type cannot return.

返回类型为底部类型的函数不能返回任何值,甚至不能返回零大小的单元类型。因此返回类型为底部类型的函数不能返回。

从这里我们也很容易发现,在一个类型系统中,bottom type 是独一无二的,它唯一地描述了函数无返回的情况。所以,有了 never 之后,any 这种脱离了类型检查的异端肯定称不上是 bottom type。

4.2 never 的妙用

never 有以下的使用场景:

  • Unreachable code 检查:标记不可达代码,获得编译提示。
  • 类型运算:作为类型运算中的最小因子。
  • Exhaustive Check:为复合类型创造编译提示。
  • ......

关于 never 的用途,知乎上有个很好的 讨论 。不可否认的是,never 这个东西很奇妙,从集合论的角度,它是一个空集合,因此它可以通过空集合的一些特性,为我们的类型运算工作带来很大便利。接下来来具体讲讲各个使用场景:

4.2.1 Unreachable code 检查

一个萌新写出了下面这行代码:

process.exit(0);
console.log("hello world") // Unreachable code detected.ts(7027)

不要笑,是真的有可能。当然这时候如果你使用了 ts,它会给你一个编译器提示:

Error: Unreachable code detected.ts(7027)

因为 process.exit() 返回类型被定义为了 never,在它之后的自然就是「unreachable code」了。

其他可能的场景还有,监听套接字:

function listen(): never {
  while(true){
    let conn = server.accept();
  }
}

listen();
console.log("!!!"); // Unreachable code detected.ts(7027)

通常来说,我们手动标记函数返回值为 never 类型,来帮助编译器识别「unreachable code」,并帮助我们收窄(narrow)类型。下面是一个没标记的例子:

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

function firstChar(msg: string | undefined) {
  if (msg === undefined)
    throwError();
  let chr = msg.charAt(1) // Object is possibly 'undefined'.
}

由于编译器不知道 throwError 是一个无返回的函数,所以 throwError() 之后的代码被认为在任意情况下都是可达的,让编译器误会 msg 的类型是 string | undefined。

这时候如果标记上了 never 类型,那么 msg 的类型将会在空检查之后收窄为 string:

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

function firstChar(msg: string | undefined) {
  if (msg === undefined)
    throw Error();
  let chr = msg.charAt(1) // ✅
}

4.2.2 类型运算

4.2.2.1 最小因子

上文提到 never 可以理解为一个空集,那么它将满足下面的运算规则:

T | never => T
T & never =>never

也就是说,never 是类型运算的最小因子。这些规则帮助我们简化了一些琐碎的类型运算,举个 例子 ,像 Promise.race 合并的多个 Promise,有时是无法确切知道时序和返回结果的。现在我们使用一个 Promise.race 来将一个有网络请求返回值的 Promise 和另一个在给定时间之内就会被 rejectPromise 合并起来。

asyncfunction fetchNameWithTimeout(userId: string): Promise<string> {
  const data = await Promise.race([
    fetchData(userId),
    timeout(3000)
  ])
  return data.userName;
}

下面是一个 timeout 函数的实现,如果超过指定时间,将会抛出一个 Error。由于它是无返回的,所以返回结果定义为了 Promise<never>

function timeout(ms: number): Promise<never> {
  return new Promise((_, reject) => {
    setTimeout(() => reject(newError("Timeout!")), ms)
  })
}

很好,接下来编译器会去推断 Promise.race 的返回值,因为 race 会取最先完成的那个 Promise 的结果,所以在上面这个例子里,它的函数签名类似这样:

function race<A, B>(inputs: [Promise<A>, Promise<B>]): Promise<A | B>

代入 fetchData 和 timeout 进来,A 则是 { userName: string },而 B 则是 never。因此,函数输出的 promise 返回值类型为 { userName: string } | never。 又因为 never 是最小因子,可以消去。故返回值可简化为 { userName: string },这正是我们希望的。

那如果在这里使用了 any 或者 unknown,结果又会怎样呢?

// 使用 any
function timeout(ms: number): Promise<any> {
  ......
}
// { userName: string } | any => any,失去了类型检查
asyncfunction fetchNameWithTimeout(userId: string): Promise<string> {
  ......
  return data.userName; // ❌ data 被推断为 any
}

any 很好理解,虽然能正常通过,但相当于没有类型检查了。

// 使用 unknown
function timeout(ms: number): Promise<unknown> {
  ......
}
// { userName: string } | unknown => unknown,类型被模糊
asyncfunction fetchNameWithTimeout(userId: string): Promise<string> {
  ......
  return data.userName; // ❌ data 被推断为 unknown
}

unknown 则是模糊了类型,需要我们手动去收窄类型。

当我们严格使用 never 来描述「unreachable code」时,编译器便能够帮助我们准确地收窄类型,做到代码即文档。

4.2.2.2 条件类型中使用

我们经常在条件类型中见到 never,它被用于表示 else 的情况。

type Arguments<T> = T extends (...args: infer A) => any ? A : never
type Return<T> = T extends (...args: any[]) => infer R ? R : never

对于上述推导函数参数和返回值的两个条件类型,即使传入的 T 是非函数类型,我们也能够得到编译器的提示:

// Error: Type '3' is not assignable to type 'never'
const x: Return<"not a function type"> = 3;

在收窄联合类型时,never 也巧妙地发挥了它作为最小因子的作用。比如说下面这个从 T 中排除 nullundefined 的例子:

type NullOrUndefined = null | undefined
type NonNullable<T> = T extends NullOrUndefined ? never : T

// 运算过程
type NonNullable<string | null> 
  // 联合类型被分解成多个分支单独运算
  => (string extends NullOrUndefined ? never : string) | (nullextends  NullOrUndefined ? never : null)
  // 多个分支得到结果,再次联合
  => string | never
  // never 在联合类型运算中被消解
  => string

4.2.3 Exhaustive Check

联合类型、代数数据类型等复合类型,可以结合 switch 语句来进行类型收窄:

interface Foo {
  type: 'foo'
}

interface Bar {
  type: 'bar'
}

type All = Foo | Bar;

function handleValue(val: All) {
  switch (val.type) {
    case'foo':
      // val 此时是 Foo
      break;
    case'bar':
      // val 此时是 Bar
      break;
    default:
      // val 此时是 never
      const exhaustiveCheck: never = val;
      break;
  }
}

如果后面有人修改了 All 类型,它会发现产生了一个编译错误:

type All = Foo | Bar | Baz;

function handleValue(val: All) {
  switch (val.type) {
    case'foo':
      // val 此时是 Foo
      break;
    case'bar':
      // val 此时是 Bar
      break;
    default:
      // val 此时是 Baz
      // ❌ Type 'Baz' is not assignable to type 'never'.(2322)
      const exhaustiveCheck: never = val;
      break;
  }
}

在 default branch 里面 val 会被收窄为 Baz,导致无法赋值给 never,产生一个编译错误。开发者能够意识到 handleValue 里面需要加上针对 Baz 的处理逻辑。通过这个办法,可以确保 handleValue 总是穷尽 (exhaust) 了 All 所有可能的类型。

5、结语

对重视类型规范和代码设计的同学来说,TypeScript 绝不是枷锁,而是一门实用主义语言。通过深入了解 never 和 unknown 在 TypeScript 类型系统中的使用和地位,可以学习到不少类型系统设计和集合论的知识,在实际开发中合理 narrow 类型,组织起可靠安全的代码。

学习过程中也发现了一本很不错的英文电子书,推荐给大家: https://exploringjs.com/tackling-ts/toc.html

参考文章:

  1. https://blog.logrocket.com/when-to-use-never-and-unknown-in-typescript-5e4d6c5799ad/
  2. https://www.zhihu.com/question/354601204

本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/rZ96wy8xUrx4T1qG5OKS0w

 相关推荐

刘强东夫妇:“移民美国”传言被驳斥

京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。

发布于:1年以前  |  808次阅读  |  详细内容 »

博主曝三大运营商,将集体采购百万台华为Mate60系列

日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。

发布于:1年以前  |  770次阅读  |  详细内容 »

ASML CEO警告:出口管制不是可行做法,不要“逼迫中国大陆创新”

据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。

发布于:1年以前  |  756次阅读  |  详细内容 »

抖音中长视频App青桃更名抖音精选,字节再发力对抗B站

今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。

发布于:1年以前  |  648次阅读  |  详细内容 »

威马CDO:中国每百户家庭仅17户有车

日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。

发布于:1年以前  |  589次阅读  |  详细内容 »

研究发现维生素 C 等抗氧化剂会刺激癌症生长和转移

近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。

发布于:1年以前  |  449次阅读  |  详细内容 »

苹果据称正引入3D打印技术,用以生产智能手表的钢质底盘

据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。

发布于:1年以前  |  446次阅读  |  详细内容 »

千万级抖音网红秀才账号被封禁

9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...

发布于:1年以前  |  445次阅读  |  详细内容 »

亚马逊股东起诉公司和贝索斯,称其在购买卫星发射服务时忽视了 SpaceX

9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。

发布于:1年以前  |  444次阅读  |  详细内容 »

苹果上线AppsbyApple网站,以推广自家应用程序

据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。

发布于:1年以前  |  442次阅读  |  详细内容 »

特斯拉美国降价引发投资者不满:“这是短期麻醉剂”

特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。

发布于:1年以前  |  441次阅读  |  详细内容 »

光刻机巨头阿斯麦:拿到许可,继续对华出口

据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。

发布于:1年以前  |  437次阅读  |  详细内容 »

马斯克与库克首次隔空合作:为苹果提供卫星服务

近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。

发布于:1年以前  |  430次阅读  |  详细内容 »

𝕏(推特)调整隐私政策,可拿用户发布的信息训练 AI 模型

据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。

发布于:1年以前  |  428次阅读  |  详细内容 »

荣耀CEO谈华为手机回归:替老同事们高兴,对行业也是好事

9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI操控无人机能力超越人类冠军

《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。

发布于:1年以前  |  423次阅读  |  详细内容 »

AI生成的蘑菇科普书存在可致命错误

近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。

发布于:1年以前  |  420次阅读  |  详细内容 »

社交媒体平台𝕏计划收集用户生物识别数据与工作教育经历

社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”

发布于:1年以前  |  411次阅读  |  详细内容 »

国产扫地机器人热销欧洲,国产割草机器人抢占欧洲草坪

2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。

发布于:1年以前  |  406次阅读  |  详细内容 »

罗永浩吐槽iPhone15和14不会有区别,除了序列号变了

罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。

发布于:1年以前  |  398次阅读  |  详细内容 »
 相关文章
Android插件化方案 5年以前  |  237227次阅读
vscode超好用的代码书签插件Bookmarks 2年以前  |  8063次阅读
 目录