使用 TypeScript 模板字面类型

2020/09/01

介绍

在今天的早些时候,Anders Hejlsberg 在 TypeScript 的仓库中发了一个 Pull Request:Template string types and mapped type as clauses。这个特性估计会在 4.1 版本中可用。

具体给 TypeScript 带来什么新特性,我就不在这里重复说了,Anders Hejlsberg 在 PR 中介绍得很清楚。这篇文章主要讨论利用这个模板字面类型,来对字符串字面类型进行一些典型的字符串操作。

清除字符串的特定前缀

实现与分析

JavaScript 的字符串中有一个实例方法 trimStart,它可以去掉字符串前面的空格字符。我们将在 TypeScript 的类型层面上实现这个功能。代码如下:

type Whitespace = ' ' | '\n' | '\r' | '\t'

type TrimStart<S extends string, P extends string = Whitespace> =
  S extends `${P}${infer R}` ? TrimStart<R, P> : S

第 1 行的 Whitespace 类型定义了常用的一些空格字符。当然 Unicode 中还定义了其它的一些空格字符,我们根据需要往 Whitespace 这个联合类型后面补充就可以。

第 3 行至第 5 行就是 TrimStart 类型的定义。这个类型需要两个类型参数,其中 S 是要被处理的字符串,P 是要被搜索并删除的前缀。这两个参数都指定 string 类型作为泛型约束。另外 TypeScript 允许我们为类型参数提供一个默认类型,在这里,类型参数 P 的默认类型是 Whitespace,表示在不指定要搜索哪些字符串的时候,默认搜索空格字符。

我们利用 conditional types 来让 TypeScript 分析字符串 S 是否匹配我们指定的 pattern:以 P 为开头,后面跟随任意字符串,同时利用 infer 关键字将后面的字符串提取出来用作被返回的类型。

如果传入的字符串匹配该 pattern,则会递归地 trim 下去,直到不再匹配这个带有指定前缀的字符串为止。

如果传入的字符串没带有该前缀或者已经完成前缀删除,就会将 S 类型原样返回。

应用

type String1 = '\t  \r  \n   value'
type Trimmed1 = TrimStart<String1>

type String2 = '---value'
type Trimmed2 = TrimStart<String2, '-'>

以上的例子中,Trimmed1Trimmed2 的类型都是 'value'

当不给 TrimStart 类型传入第二个类型参数时,默认使用 Whitespace 类型;若提供,则可以删除指定的前缀。

字符串替换

实现与分析

这里我分别实现了单次替换和全部替换。代码如下:

type ReplaceOnce<Search extends string, Replace extends string, Subject extends string> =
    Subject extends `${infer L}${Search}${infer R}` ? `${L}${Replace}${R}` : Subject

type ReplaceAll<Search extends string, Replace extends string, Subject extends string> =
    Subject extends `${infer L}${Search}${infer R}` ? ReplaceAll<Search, Replace, `${L}${Replace}${R}`> : Subject

ReplaceOnce 类型和 ReplaceAll 类型需要三个类型参数:Search 类型是要被搜索的字符串;Replace 类型用于找到 Search 后,替换掉原来的 SearchSubject 就是要被处理的字符串。

ReplaceOnce 类型和 ReplaceAll 类型很相似:都用到了 conditional types,而且条件还相同。不同的是条件判断通过后返回的类型不同。

在上面的 conditional types 的条件中,我们对 Subject 字符串进行 pattern 匹配:检查字符串中是否包含 Search 字符串。如果包含,则还利用 infer 关键字将位于 Search 左边、右边的字符串提取出来用于返回。需要注意的是,尽管在我们的 pattern 中 Search 在中间,但这并不意味着 Search 一定要在中间才算匹配——放在开头或末尾也是可以的(放在末尾时,可能存在某些 edge cases 不能被匹配,这应该是 TypeScript 的问题),而这时候 infer Linfer R 推断出来的类型是一个空字符串字面类型,即 '' 类型。

如果字符串匹配我们的 pattern,则利用传入的 Replace 字符串代替原来的 Search 字符串,同时使用之前提取出来的类型 L 和类型 R 来组成新的字符串:${L}${Replace}${R}

到这里,我们已经完成一次替换了。对于 ReplaceOnce 类型,现在就可以将刚刚生成的新字符串类型返回;而对于 ReplaceAll 类型,则将这个新的字符串类型作为 Subject 参数,再次传入 ReplaceAll 类型中然后递归下去,直到所有字符串都被替换。

应用

type String2 = 'process'
type Replaced1 = ReplaceOnce<'s', 'x', String2>
type Replaced2 = ReplaceOnce<'ss', 'x', String2>
type Replaced3 = ReplaceAll<'s', 'x', String2>

在上面的例子中,Replaced1 的类型是 'procexs'Replaced2 的类型是 'procex'Replaced3 的类型是 'procexx'。结果符合预期。

检查是否包含指定的子字符串

实现与分析

这个可以由上面的「字符串替换」简化而来:

type IsIncludes<Needle extends string, Haystack extends string> =
  Haystack extends `${infer L}${Needle}${infer R}` ? true : false

Needle 就是要查找的子字符串,Haystack 就是要被搜索的字符串。conditional types 中的 pattern 与上面「字符串替换」中的 pattern 一样。最后根据匹配与否返回 true 类型或 false 类型即可。

应用

type String2 = 'process'
type Included = IsIncludes<'pro', String2>
type NotIncluded = IsIncludes<'pre', String2>

在上面的例子中,Included 的类型为 trueNotIncluded 的类型为 false


最后,这里有我写的一份代码,我把它放在 TypeScript 的 Playground 上。

全文完。