巧用 TypeScript 的「条件类型」

2018/11/10

约定

本文假定读者对 TypeScript 有一定的了解,比如你得知道什么是交叉类型(intersection type)和联合类型(union type)。

起因和需求

最近在以 Node API 的方式来调用 typescript-estree 的时候,发现它虽然用 TypeScript 重写了,但是里面 parse 函数的返回值被打上了 any 类型。

这种做法我可以理解,毕竟在将一个 JavaScript 迁移到 TypeScript 时,的确会有可能暂时性地将某些类型标为 any

但这对下游开发者相当不方便。你想,明明上游库是用 TypeScript 写的,但不能好好利用 TypeScript 带来的静态类型的好处。因此我决定给这个项目提 PR

问题

typescript-estreeparse 函数返回的是 ESTree.Program,这里不讨论为什么是这个类型。然而,parse 函数接收两个参数,第一个是源码(类型为 string),第二个是 options(类型为 ParserOptions,是个 JavaScript 对象)。通过控制 options 里某些选项的开关,可以让该函数返回更多信息(即,返回的对象上会有额外的 ESTree.Program 以外的属性)。例如,如果给 options 传递 { tokens: true },那么 parse 函数返回的对象上就会多出一个 tokens 属性。(为方便叙述,我将 parse 函数的返回值类型称为 ParserResult

也就是说,这里额外的属性是否存在,取决于 options 里选项的情况。这意味着,你不能写死 parse 函数的返回值类型。

你可能会讲,可以给 ParserResult 添加这些额外的属性,然后给它们打上 optional 的标记,就像这样:

type ParserResult = Program & { tokens?: ESTreeToken[] }

打上 ? 标记意味着,这个属性的类型除了可能是你指定的之外,它还有可能是 undefined,即,下面的代码与上面的作用是一样的(但在编辑器或 IDE 的 auto complete 中会有差别):

type ParserResult = Program & { tokens: ESTreeToken[] | undefined }

这样做有什么缺点呢?假如我没在 options 中指定 { tokens: true } 那还好,因为这种情况下,tokens 的确不存在于 ParserResult 中。但如果我指定了呢?像这样:

const result = parse('', { tokens: true })
result.tokens.forEach(/* do something */)

上面的代码是不能编译通过的,因为按照前面的做法,TypeScript 认为 tokens 有可能是 undefined,因此不允许直接使用 tokens 属性,而是先做非空判断:

const result = parse('', { tokens: true })
result.tokens && result.tokens.forEach(/* do something */)

这就让代码变得冗余。你明明知道 tokens 一定存在于 result 上,却还要傻傻地、多余地去判断。使用 as 去强制告诉编译器 tokens 一定存在,这种做法同样是麻烦的,与上面的相比只是没有运行时开销。

解决

引入 conditional type

TypeScript 从 2.8 起引入了 conditional type,这里简单介绍 conditional type 的用法。

比如,假设我们已经定义了 TypeATypeBTypeCTypeD 四个不同的类型:

type NewType = TypeA extends TypeB ? TypeC : TypeD

这表示,如果 TypeATypeB 的子类型,那么 NewType 就是 TypeC,否则是 TypeD

值得注意的是,虽然 conditional type 的语法使用了 ternary,但它实际上并不是一个真正的表达式,仅仅是类型注解。也就是说,像上面的语句会在编译时被去掉。又因为 TypeScript 是静态类型语言,因此所有的类型信息都能在编译时计算出来,即,对于 NewType 究竟是 TypeC 还是 TypeD 这一点是确定的。

有了 conditional type,我们就能利用它去判断传递给 options 的情况了。

ParserResult 添加额外属性

前面提到,parse 函数的返回值类型是 ESTree.Program,那么给该返回值添加更多的属性只需要使用交叉类型:

type ParserResult = ESTree.Program & { tokens: ESTreeToken[] }

整合

这里还要使用 TypeScript 提供的另外一个特性——泛型。为什么需要泛型呢?因为实际传递给 options 的类型是不固定的。

为了让下游开发者不会出错以及方便编辑器的 auto complete(实际上对于后面判断 options 而言这也是必须的),我们要用泛型约束:

type ParserResult<T extends ParserOptions> = /* ... */

然后 parse 函数的函数签名就是:

function parse(code: string, options: ParserOptions): ParserResult<typeof options> {}

接下来继续完善 ParserResult 的类型声明。

我们可以分析出,如果 options 上存在 tokens 属性且该属性的值为 true,那么 ParserResult 上应该有 tokens 属性。

因此 conditional type 这一部分可以写成:

T['tokens'] extends true ? { tokens: ESTreeToken[] } : {}

这里对上面的代码作点解释:

第一,T['tokens'] 表示获取 T 上的 tokens 属性的类型,这里的语法与 JavaScript 中获取一个对象上的属性的语法类似(如:obj['prop'])。但不能写成 T.tokens,因为 T 只是一个类型,不是一个值,并且这是 TypeScript 的语法要求。

第二,true 在这里不是表示 JavaScript 中的 true 值,而是 TypeScript 中的 true 这个类型。因为在 TypeScript 中字面量也可以作为类型,而 true 类型就表示 JavaScript 中字面量 true 的类型。(不一定是 boolean,这个要看情况)

第三,{ tokens: ESTreeToken[] } 表示一个包含 tokens 属性的对象的类型,这也是个类型,不是值。tokens 属性随后能够因交叉类型而合并到 ESTree.Program 中。

第四,{} 表示一个没有任何属性的对象的类型。由于这只是类型,它并不是 Object 的实例,因此不要觉得会有 hasOwnProperty 这样的玩意。像这样一个没有任何属性的对象类型被合并到 ESTree.Program 中后,不会增加任何新属性。

最后,ParserResult 的类型声明就是:

type ParserResult<T extends ParserOptions> = ESTree.Program &
  (T['tokens'] extends true ? { tokens: ESTreeToken[] } : {})

实际的表现是,如果 ParserOptions 中的 tokens 属性的类型(注意我一直在说「类型」而不是「值」)为 true,那么 ParserResult 的类型就是 ESTree.Program & { tokens: ESTreeToken[] },否则就是 ESTree.Program

更深入一些

到这时候,ParserResult 是没问题的了。比如你可以做像这样的测试:

let result: ParserResult<{ tokens: true }>

这时候你能够在 result 中拿到 tokens 这个属性。反过来,如果把 tokens 设为 false,就不行。

然而上面的代码仅仅是测试,实际中你不可能写出那样的代码,因为 ParserResult 是由 parse 函数返回过来的,而不是自己定义的。

我们再做一个测试:

let result = parse('', { tokens: true })

你会发现(实际上我一开始就是遇到这种情况),你已经给 options 传递 { tokens: true } 了,但 TypeScript 还是告诉你 result 上不存在 tokens 属性。

问题出在了 parse 函数的函数签名上。我们再看一次它的函数签名:(这样你就不需要滚回看上文)

function parse(code: string, options: ParserOptions): ParserResult<typeof options> {}

返回值类型中的 typeof options 表示获取参数 options 的类型。而参数 options 被写死了(尽管 ParserOptions 这没错)。typeof options 拿到的类型也只能是 ParserOptionsParserOptions 是已经定义好的,跟实际传递的值没什么关系。

既然要获取实际传入的值,就得再次动用泛型了,不过这次是使用在 parse 函数上。同样要加上泛型约束。同时为了避免下游开发者的麻烦以及困惑,这里给泛型加上默认类型。如下所示:

function parse<T extends ParserOptions = ParserOptions>(
  code: string,
  options: T
): ParserResult<T> {}

这时候,TypeScript 就会去计算实际传入 options 的值的类型,并将此类型进一步传递给 ParserResult

再次做前面那个测试:

let result: ParserResult<{ tokens: true }>

这时候 TypeScript 就能正确提示 result 上存在 tokens 属性,并且类型为 ESTreeToken[]

全文完毕。