likes
comments
collection
share

Typescript 5.5 新特性 Inferred Type Predicates 的来龙去脉特性介绍 Typesc

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

特性介绍

Typescript 5.5 新增了一个叫做Inferred Type Predicates的新特性,这个特性来源于一个七年前提的issue。 在 Typescript 中类型系统可以基于对代码控制流的分析来对变量的类型进行推断,比如:

declare const value: number | string;

if (typeof value === "number") {
  value; // number
} else {
  value; // string
}

Typescript 可以根据代码分析进行类型推断,在 true 分支中value的类型是number,否则是类型就是string。但是如果我们把typeof的判断逻辑抽象成一个函数的话,Typescript 就无法帮助我们进行准确的类型推断了:

declare const value: number | string;

function isNumber(value: number | string) {
  return typeof value === "number";
}

if (isNumber(value)) {
  value; // number | string
} else {
  value; // number | string
}

如果希望 Typescript 可以帮我们进行准确的类型推断,那么我们需要将isNumber改成一个返回类型守卫(Type Guard)的函数:

declare const value: number | string;

function isNumber(value: number | string): value is number {
  return typeof value === "number";
}

if (isNumber(value)) {
  value; // number
} else {
  value; // string
}

其实 Typescript 本身是可以帮助我们识别类型守卫的,这样可以避免显式的声明类型守卫,这个问题在一些数组的操作中能够体现的更明显:

declare const list: Array<number | null>;

const res = list.filter((item) => typeof item === "number"); // (number | null)[]

上面的例子中 Typescript 是无法帮我们推断出res的准确类型的,我们同样需要手动的声明类型守卫。 Typescript 5.5 中引入的 Inferred Type Predicates 特性可以很好的解决这个问题,他可以让我们在大多数情况下省去声明类型守卫的工作,可以进行更准确的类型推断:

declare const value: number | string;

// function isNumber(value: number | string): value is number
function isNumber(value: number | string) {
  return typeof value === "number";
}

if (isNumber(value)) {
  value; // number
} else {
  value; // string
}

declare const list: Array<number | null>;

const res = list.filter((item) => typeof item === "number"); // number[]

当一个函数满足下面这四个条件的时候,Typescript 就会自动将函数的返回推断成类型守卫:

  1. 函数没有显式的声明返回类型或者声明类型守卫;
  2. 函数只有一个return语句,同时没有隐式的返回语句;
  3. 函数不会修改函数参数;
  4. 函数返回一个 boolean 值的表达式,同时这个表达式可以对函数参数进行更准确的类型推断;

除了上面的这些条件以外,还有一些例外的情况:

declare const list: Array<number | null>;

const res = list.filter((item) => !!item); // (number|null)[]

这里用了!!来排除null的情况,但是忽略了一个情况就是!!-1返回也是false,所以这里并不能帮助我们进行类型推断。能够自动识别类型守卫有一个默认的前提:当且只当表达式返回 true 时,itemnumber类型,否则item不是number类型的时候,Typescript 才会自动识别类型守卫,也就是说表达式是false的时候,item必须不是number类型。

新特性背后的故事

打开最开始提到的那个 issue,你会发现这个新特性的开发正式当时提这个 issue 的人,在官方发布的更新公告中提到了新特性开发者Dan Vanderkam(为了方便,我们后面都叫他 Dan,他也是Effective Typescript这本书的作者)的一篇关于新特性从 0 到发布的博客,Dan 在博客中很详细的记录了整个特性的开发过程,Dan 跟我们一样是一个 Typescript 的使用者,他这篇文章给了我一些关于如何从框架使用者到框架贡献者的启发,我把这篇博客的大致内容翻译在下面(内容有所省略),有兴趣的可以去看看博客原文:

分割线------------------------------------------------------------

The Making of a TypeScript Feature: Inferring Type Predicates

为什么要给 Typescript 贡献代码?

我从 2016 年开始使用 Typescript。同时我从那是开始分享一些跟它相关的博客。但是我从来没有给它贡献过代码。这让我感觉对 Typescript 和它的生态系统的理解存在偏差。就像大多数 TS 用户一样,我有一系列希望 TS 支持的特性清单,我觉得学习编译器内部相关的内容能够让我对于这些特性是否是可行的有所了解。

期待和担忧

我期待通过这个过程我可以对 Typescript 内部运行的原理有足够的了解,也许还能有一些自己的见解。如果运气好的话,也许我可以告诉别人我是 Typescript 的贡献者,对这个语言进行了一些提升。

我最大的担忧是我花了很大精力提了一个 PR,但是它却一直停滞不前。已经有一些前车之鉴了,最有名的就是cake-driven development incident。我明白我真正的目的是学习 Typescript,但是我还是希望我的 PR 可以被接受。

发现第一个 issue

在开始实现具体的特性之前,我认为我应该从修复一个小 bug 开始。这有助于熟悉编译器和开发流程。这也正是 Typescript 文档对贡献者的建议。

找到一个容易上手的 issue 对我来说比我预想的要困难。大多数很小的,新提交的 issue 很快就会被其他有经验的社区成员修复。从一个社区的角度,issue 很快就能被修复是很棒的:你提交的 bug 都会被修复。但是对于新来的贡献者来说,这并不友好:我很难比这些人更快修复 bug。 我意外的发现了#53182,这个 bug 是说数字之间的分隔符在编译的结果中无法被保留,这个 bug 看起来比较容易修,而且我是提这个 bug 的开发者的粉丝。

这个 bug 的修复只需要一行代码,但是在这个过程中我学习了很多 Typescript 的内部原理。Typescript 的代码结构遵循了很多最佳实践。所有关于类型检查的代码都在一个叫checker.ts的文件中,这个文件有五万多行代码。

尽管如此,还是有很多工具可以来协助我们学习。VS Code 中的 debugging 功能可以很好的帮助学习编译器具体做了什么工作。代码库里面基本没有单元测试,但是有很多"baseline",这是一些实例代码的类型和错误的快照。

几周之后,这个 issue 的 PR 被合并同时作为 Typescript 5.4 的一部分发布了。我成为了一个官方的 Typescript 贡献者!

再接再厉

修复一个小 bug 是一个好的开始,但是我更大的目标是实现一个新特性。我在 Typescript 中提的所有 issue 中,[#16069]收获了超过 500 个 👍🏻。这个 issue 是希望 Typescript 可以推断像x => x !== null这样的函数的类型谓语(type predicates)。很显然,我并不是唯一一个想要这个特性的人。

我有理由认为这个 issue 是可以被解决的。在Typescript 4.4(2021)中,Anders 新增了aliased conditions and discriminants的支持,可以让你这样写代码:

function foo(x: string | null) {
  const ok = x !== null;
  if (ok) {
    x; // type is string
  }
  return ok;
}

ok肯定存储了一些信息来表示它的值和x类型推断相关。如果我查看 Anders 的 PR,我应该能找到这些信息存储在哪儿。这个看起来跟我要解决的问题相关联。但是结果证明,我的猜想错了。ok并没有存储任何内容,我对类型推断的工作原理的理解错了。这对我来说是一个很大的收获。我写了一篇关于这个的博客(译者注:推荐阅读)。作为我理解控制流分析如何工作的一部分努力,我为 Typescript 写了一个控制流程图可视化的工具,我把它作为一个新的特性贡献在了TS AST Viewer。这很棒,即使我在 type predicate inference 的努力白费了,至少我为生态系统做了一些贡献。

还有是希望的

在对类型推断有了很多理解之后,我重新回到了最初的问题。这些理解让我实现我的特性变得更难还是更简单的呢? 每当我解释这个特性的时候,我都会给你演示如何将 return 表达式放到一个 if 判断中来看看参数类型被收窄到什么类型了:

function isNonNull(x: number | null) {
  return x !== null;
}

// ->

function isNonNullRewrite(x: number | null) {
  if (x !== null) {
    x; // type is number
  }
}

这里我意识到我可以在控制流程图中做同样的事情。我可以在 return 语句的地方插入一个FlowCondition节点,来看看在条件判断是 true 的时候,函数参数会是什么类型。如果参数的类型和函数中声明的不一样,那么我就得到了一个 type predicate!

我可以通过getFlowTypeOfReference查看参数在函数某个位置的类型。但是我要把这个逻辑写在哪里呢?这是一个挑战,我很意外的发现我可以把这个逻辑放在getTypePredicateOfSignature中的一个地方。我添加了一个新函数getTypePredicateFromBody,这个函数会在函数返回值的函数情况下被调用。 因为这是我第一次编写类型检查器,这个工作对我来说有点困难。就算很简单的事情感觉都十分困难。Declaretion,SymbolIdentifier有什么区别,具体都表示什么含义?我经常先用一个比较迂回的实现让我可以继续推进这个特效,直到我之后找到了更规范的实现方式。比如,如果param是一个ParameterDeclaration,那么你就可以使用param.name来获取一个BindingName,以及使用isIdentifier(param.name)来确保它是一个Identifier

在我学习 Typescript 源码的过程中,我发现一个记笔记是一个很有用的办法。每个函数都是用来做什么的?我有什么疑问?我对哪些内容感觉困惑?我还有什么内容还没完成?这些帮助我可以跟踪整个过程。特别是当我回头去看我几周之前写的问题时,我发现我已经有答案了,这是很有成就感的。很明显我一直在学习。当我的 PR 被合并的时候,我的 Notion 文档已经达到了 70 多页。

很幸运我可以把所有碎片拼凑起来,最终实现了这个特效,这特别鼓舞我!

测试失败

开发完成之后我运行了 Typescript 18000+ "baseline"。这是一个令人兴奋的时刻,我第一次可以看到我的类型推断算法在各种代码下的表现究竟如何!我最开始的实现产生了 43 个测试失败。我把他们进行了分类:

  • 32 个"Maximum call stack size exceeded"错误
  • 5 个是直接返回 boolean 参数的函数
  • 一个是由于我的失误导致的失败
  • 其他 5 个是符合预期的失败 下面这个场景很有趣:
function identity(b: boolean) {
  return b;
}

给这种函数推断类型守卫似乎没有太大意义,我添加了一个特殊逻辑来跳过 boolean 类型的参数。"Maximum call stack error"是因为死循环导致的。我添加了一些代码来处理这种情况。

在更多地方支持类型守卫推断

为了让实现尽可能简单,我只考虑了函数语句,并没有考虑函数表达式或者剪头函数。现在我验证了最基本的场景,接下来我也要支持这些场景。

单独的函数表达式和剪头函数并不难支持,但是我在处理函数参数类型取决于上下文的场景时,遇到了一些麻烦。例如:

const xs = [1, 2, 3, null];
const nums = xs.filter((x) => x !== null);

x的类型是numer | null,但是得到了一直是any类型。这个问题并不难,我只需要找到正确的函数。getTypeForVariableLikeDeclaration起不到作用,但是我意外发现了getNarrowedTypeOfSymbol可以工作。在最后的 PR 中,我改成了getSymbolLinks。最终我支持了这些场景。

提交 PR

我向Josh Goldberg展示了我的 PR。我有点紧张,因为我花了很多时间在这个 PR 上。Josh 很兴奋同时给了我很多鼓励。之后给 PR 写了一个详细的描述并且提交了。

这整个过程令人感到兴奋!看到 Twitter 上面积极的反馈是很有趣并且很受鼓舞的。特别是 Brad Zacher 给我介绍了 Flow 中的%checks%特性。他的经验对我后来扩大我 PR 的范围有很大帮助。

在提交了 PR 之后,Dan 还针对一些特殊场景进行了优化,并且修复了一些边界情况下的 bug,具体内容可以去他的博客中了解。

分割线------------------------------------------------------------

总结

这篇文章从 Typescript 5.5 中 Inferred Type Predicates 新特性延伸开,了解了这个特性背后的故事,相信这个故事可以给我像我一样想给开源项目贡献代码的开发者很多启发和鼓励:

  1. 想要成为一个开源项目的贡献者,首先得是这个项目的深度使用者;Dan 从 2016 年就开始使用 Typescript,同时还写了一本关于 Typescript 的书。
  2. 对于一个新人贡献者来说,从修复开源项目的 issue 开始,是一个很好的上手办法。
  3. 通过 debug 的方法来了解源码逻辑是一个很好的办法。
  4. 在开发的过程中可以多做记录,可以帮助梳理思路以及做复盘。
  5. 开源项目完善的测试是必不可少的,可以确保在多人合作开发的场景下的代码质量。
  6. 在阅读源码的过程中必然会存在许多不理解的地方,一开始不必过于纠结,可以先记下来,等后面有了很多了解之后再回头看。不要放弃,看一次不理解就多看几次,顿悟往往存在于永不放弃的过程中
转载自:https://juejin.cn/post/7384376473648267283
评论
请登录