聊聊 TypeScript Template Literal

Created
May 28, 2021 09:16 PM
Tags
typescript

前言

我们都知道 TS 最大的优点之一就是对类型的细腻控制。我们只需通过 Type Manipulation、Utility Types 、Class 等功能,便可做类型体操,尝类型百态
作为一个 TS 新手,在 TS 项目中,我都尽量达到我认为的最佳实践:任何变量的类型必须要拿捏地足够细腻,怎么说呢,细腻到那种接近烦人的程度,哈哈哈
不过呢,有一种类型一直是我在追求类型精确不归路上的绊脚石

字符串类型

好吧!我要吐槽的就是 string 类型,一个在 JS 世界里功能和使用度都不亚于对象的类型
在一般的 TypeScript 项目中,我们引入 TS 主要是为了对强化对象、函数参数的类型细化,在编译时(甚至说在我们在支持 TypeScript Server 的 IDE 中写代码时)减少出错;进一步,通过类型推断,我们还能获得更多属性和方法的提示及存在性验证
虽然 string 类型也有上述优势,还可以通过 EnumUnion 精确它,但是字符串本身的范围就太大,我希望细化它,但又不想写臃肿的 EnumUnion

Template Literal Type 🎉

面向类型编程的语言怎么会缺失这种功能呢?Google 一番后,我成功找到了字符串类型细化的技巧:Template Literal Type
 
在 JS 的世界里,Template Literal 是把变量和表达式动态引入字符串的方法,我们把这句话中的把`变量和表达式` 替换成 `类型和类型操作` 就成了 TypeScript 中的 Template Literal Type:把类型和表达式动态引入 string 类型的方法类型
我们可以把它和 Union 、Generics 、... 一起用,使简短的模板字符串类型拥有强大的表达力
下面我们就一起来看几个小例子,体验一下模板字符串类型

1. Union + Template Literal

如果说 Union 赋予了类型加减的能力,那么配合上 Template Literal 就有了乘除的能力(因为本质上这是个组合问题),我们看看下面的代码:
type BelovedCatType = "Snowshoe" | "British Shorthair";
type BelovedDogType = "Corgi" | "AndCorgi";

type BelovedCatAndDogCombo = `${BelovedCatType}-${BelovedDogType}`;
// "Snowshoe-Corgi" | "Snowshoe-AndCorgi" | "British Shorthair-Corgi" | "British Shorthair-AndCorgi"
 
我们可以把 Union 中的 | 操作符当成是可能性的加法操作符,那么模板字符串中的 Union 就是在进行可能性的乘法操作。具体一点讲,上例中EmailLocaleIDsFooterLocaleIDs 两种类型各有两种可能性,当把他们作为整体组合成模板字符串类型后就有 2 x 2 = 4 种可能性,当然单单实现这一点还是挺简单的,我们把 EmailLocaleIDsFooterLocaleIDs 再 Union 一次也能得到 4 种可能性,但完全没有 Union 自由。由此,我们可以看出模板字符串类型的两个优点:
  1. 支持可能性的乘法操作,类型表达更广泛
  1. 支持类型的再生和组合,充分复用类型

2. Enum + Template Literal

枚举的可能值也就相当于 Union 中的可能值,所以也能再模板字符串中发挥作用,枚举比 Union 方便之处在于它不仅可以在代码中当作常量来使用,还能参与到 TS 的类型系统中(Enum 中的 keys 还可以用作 Type Manipulation)。同上,我们来看一个例子:
 
enum LogLevel {
  ERROR,
  WARN,
  INFO,
  DEBUG,
}

type LogLevelSignal = `level-${LogLevel}`
// "level-0" | "level-1" | "level-2" | "level-3"
type LogLevelType = `TYPE-${keyof typeof LogLevel}`
// "TYPE-ERROR" | "TYPE-WARN" | "TYPE-INFO" | "TYPE-DEBUG"
在这个例子中,LogLevelSignal 复用 LogLevel 的枚举值,LogLevelType 复用了 LogLevel 的 Key,所以 Enum 除了具有 Union 的优点外,还有用键组织模板字符串的能力

3. Primitives Types + Template Literal

 
在 Template Literal 中嵌入原始类型前,我们必须知道它支持嵌入的类型:
只有 string | number | bigint | boolean | null | undefined 能够被正确编译
并且经过尝试,boolean | null | undefined 都转化为对应的字面字符串,也就是 "true" | "false" | "null" | "undefined"
所以真正有用的类型就可能只有 string | number | bigint ,假设我们有一个对象,它的键都以 on 开头,以 End | Start 结尾,其余部分都相对自由,那么我们可以这样定义:
type EventLifeStatus = `on${string}End` | `on${string}Start`
type EventMap = Map<EventLifeStatus, string>
这样我们既限制了 EnvetLifeStatus 的开头结尾,中间部分又留有充分的自由空间。
注意到,我们使用 Map 而不是 Object,因为使用 typeinterface 关键字定义的 Object 的索引类型限制比较严格:
type EventObject = {
	[K: EventLifeStatus]: string
}
// An index signature parameter type must be either 'string' or 'number'.
同时,在模板字符串类型中嵌入原始类型后,它不会变成 Union 类型(因为可能性是无穷的),那么下面这种语法也不会生效:
type EventObject = {
	[K in EventLifeStatus]: string
}
// => type EventObject = {}
TypeScript Repo 里有个放宽对象索引签名类型的提案,专门用来追踪这个问题:
个人认为,在模板字符串中使用原生类型就代表了无穷种可能,相对于 Object,Map 则更加适合用来表示可能性无穷的索引,并且在大多数时候,Map 的性能也不会输给 Object
 

4. Generics + declare + Template Literal

 
为模板字符串引入 Generics 其实没有必要,因为字符串这种比较基础的类型,一般都内嵌在多个类型中,当我们在模板字符串中引入 Generics 后,使用它的外部类型也要声明对应的 Generics, 这很麻烦,不够实用
但是,可以用 declare 补救(来自官方文档的一个例子):
type PropEventSource<Type> = {
  on<Key extends string & keyof Type>
      (eventName: `${Key}Changed`, callback: (newValue: Type[Key]) => void ): void;
};

declare function makeWatchedObject<Type>(obj: Type): Type & PropEventSource<Type>;

const person = makeWatchedObject({
  firstName: "Saoirse",
  lastName: "Ronan",
  age: 26
});

person.on("firstNameChanged", newName => {
  console.log(`new name is ${newName.toUpperCase()}`);
});

person.on("ageChanged", newAge => {
  if (newAge < 0) {
      console.warn("warning! negative age");
  }
})

总结

Template Literal Type 属于 Type Manipulation 的一种,它解决了字符串的细化粒度问题,虽然不一定常用,但是 Type Manipulation 中的骚操作,才是 TypeScript 的灵魂啊!