18.基础类型新成员:模板字符串类型入门

10/9/2023

上一节,我们对内置工具类型的进阶方向进行了实现,它们中的部分工具类型确实相对烧脑和难以理解。这一节,我们稍作驻足,放慢节奏,来学习 TypeScript 的一个特殊存在:模板字符串类型

此前我们已经学习了泛型相关的概念,知道它的表现就像 JavaScript 中的函数参数一样,接受一组参数,处理,然后返回一个新的值。而模板字符串类型,其实也完全可以映射到 JavaScript 中的概念——模板字符串。

本节代码见:Template String Types (opens new window)

# 模板字符串类型的基础使用

我们来看一个最简单的使用例子:

type World = 'World';

// "Hello World"
type Greeting = `Hello ${World}`;
1
2
3
4

这里的 Greeting 就是一个模板字符串类型,它内部通过与 JavaScript 中模板字符串相同的语法(${}),使用了另一个类型别名 World,其最终的类型就是将两个字符串类型值组装在一起返回

除了使用确定的类型别名以外,模板字符串类型当然也支持通过泛型参数传入。需要注意的是,并不是所有值都能被作为模板插槽:

type Greet<T extends string | number | boolean | null | undefined | bigint> = `Hello ${T}`;

type Greet1 = Greet<"linbudu">; // "Hello linbudu"
type Greet2 = Greet<599>; // "Hello 599"
type Greet3 = Greet<true>; // "Hello true"
type Greet4 = Greet<null>; // "Hello null"
type Greet5 = Greet<undefined>; // "Hello undefined"
type Greet6 = Greet<0x1fffffffffffff>; // "Hello 9007199254740991"
1
2
3
4
5
6
7
8

目前有效的类型只有 string | number | boolean | null | undefined | bigint 这几个。正如上面的例子所示,这些类型在最终的字符串结果中都会被转换为字符串字面量类型,即使是 null 与 undefined。

当然,你也可以直接为插槽传入一个类型而非类型别名:

type Greeting = `Hello ${string}`;
1

在这种情况下,Greeting 类型并不会变成 Hello string,而是保持原样。这也意味着它并没有实际意义,此时就是一个无法改变的模板字符串类型,但所有 Hello开头的字面量类型都会被视为 Hello ${string} 的子类型,如 Hello LinbuduHello TypeScript

很明显,模板字符串类型的主要目的即是增强字符串字面量类型的灵活性,进一步增强类型和逻辑代码的关联。通过模板字符串类型你可以这样声明你的版本号:

type Version = `${number}.${number}.${number}`;

const v1: Version = '1.1.0';

// X 类型 "1.0" 不能赋值给类型 `${number}.${number}.${number}`
const v2: Version = '1.0';
1
2
3
4
5
6

而在需要声明大量存在关联的字符串字面量类型时,模板字符串类型也能在减少代码的同时获得更好的类型保障。举例来说,当我们需要声明以下字符串类型时:

type SKU =
  | 'iphone-16G-official'
  | 'xiaomi-16G-official'
  | 'honor-16G-official'
  | 'iphone-16G-second-hand'
  | 'xiaomi-16G-second-hand'
  | 'honor-16G-second-hand'
  | 'iphone-64G-official'
  | 'xiaomi-64G-official'
  | 'honor-64G-official'
  | 'iphone-64G-second-hand'
  | 'xiaomi-64G-second-hand'
  | 'honor-64G-second-hand';
1
2
3
4
5
6
7
8
9
10
11
12
13

随着商品、内存数、货品类型的增加,我们可能需要成几何倍地新增。但如果使用模板字符串类型,我们可以利用其自动分发的特性来实现简便而又严谨的声明:

type Brand = 'iphone' | 'xiaomi' | 'honor';
type Memory = '16G' | '64G';
type ItemType = 'official' | 'second-hand';

type SKU = `${Brand}-${Memory}-${ItemType}`;
1
2
3
4
5

在插槽中传入联合类型,然后你就会发现,所有的联合类型排列组合都已经自动组合完毕了:

img

你可能会想,如果某一种组合并不存在,就像 iphone-32G 系列?我们在内置工具类型环节中提到了作为类型编程范式之一的集合工具类型,使用差集就可以解决这里的问题,比如我们可以只是剔除数个确定商品集合,也可以再利用模板字符串类型的排列组合能力生成要剔除的集合

通过这种方式,我们不仅不需要再手动声明一大堆工具类型,同时也获得了逻辑层面的保障:它会忠实地将所有插槽中的联合类型与剩余的字符串部分进行依次的排列组合

除了直接在插槽中传递联合类型,通过泛型传入联合类型时同样会有分发过程:

type SizeRecord<Size extends string> = `${Size}-Record`;

type Size = 'Small' | 'Middle' | 'Large';

// "Small-Record" | "Middle-Record" | "Huge-Record"
type UnionSizeRecord = SizeRecord<Size>;
1
2
3
4
5
6

模板字符串类型和字符串字面量类型实在太过相似,我们很容易想到它和字符串类型之间的类型兼容性是怎样的。

# 模板字符串类型的类型表现

实际上,由于模板字符串类型最终的产物还是字符串字面量类型,因此只要插槽位置的类型匹配,字符串字面量类型就可以被认为是模板字符串类型的子类型,比如我们上面的版本号:

declare let v1: `${number}.${number}.${number}`;
declare let v2: '1.2.4';

v1 = v2;
1
2
3
4

如果反过来,v2 = v1 很显然是不成立的,因为 v1 还包含了 100.0.0 等等情况。同样的,模板字符串类型和模板字符串也拥有着紧密的关联:

const greet = (to: string): `Hello ${string}` => {
  return `Hello ${to}`;
};
1
2
3

这个例子进一步体现了类型与值的紧密关联,通过模板字符串类型,现在我们能够进行更精确地类型描述了。而作为基础类型能力,模板字符串类型和其他类型工具也有着奇妙的组合作用,比如索引类型和映射类型。

# 结合索引类型与映射类型

说到模板字符串插槽中传入联合类型的自动分发特性时,你可能会想到我们此前接触的一个能够生成联合类型的工具:索引类型查询操作符 keyof。基于 keyof + 模板字符串类型,我们可以基于已有的对象类型来实现精确到字面量的类型推导:

interface Foo {
  name: string;
  age: number;
  job: Job;
}

type ChangeListener = {
  on: (change: `${keyof Foo}Changed`) => void;
};

declare let listener: ChangeListener;

// 提示并约束为 "nameChanged" | "ageChanged" | "jobChanged"
listener.on('');
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在需要基于已有的对象类型进行字面量层面的变更时,我们现在能够放心地将这部分类型约束也交给模板字符串类型了。而除了索引类型,模板字符串类型也和映射类型有着奇妙的化学反应。

为了与映射类型实现更好的协作,TS 在引入模板字符串类型时支持了一个叫做 重映射(*Remapping*) 的新语法,基于模板字符串类型与重映射,我们可以实现一个此前无法想象的新功能:在映射键名时基于原键名做修改

我们可以使用映射类型很容易复制一个接口:

type Copy<T extends object> = {
  [K in keyof T]: T[K];
};
1
2
3

然而,如果我们想要在复制时小小的修改下键名要怎么做?比如从 namemodified_name ?修改键值类型我们都很熟练了,但要修改键名,我们就需要本节的新朋友搭把手才可以。

我们直接看如何基于重映射来修改键名:

type CopyWithRename<T extends object> = {
  [K in keyof T as `modified_${string & K}`]: T[K];
};

interface Foo {
  name: string;
  age: number;
}

// {
//   modified_name: string;
//   modified_age: number;
// }
type CopiedFoo = CopyWithRename<Foo>;
1
2
3
4
5
6
7
8
9
10
11
12
13
14

这里我们其实就是通过 as 语法,将映射的键名作为变量,映射到一个新的字符串类型。需要注意的是,由于对象的合法键名类型包括了 symbol,而模板字符串类型插槽中并不支持 symbol 类型。因此我们使用 string & K 来确保了最终交由模板插槽的值,一定会是合法的 string 类型。

我们也可以通过伪代码来帮助理解:

const Copied = {};
for (const K in Object.keys(T)){
  const Key = `modified_${K}`;
  Copied[Key] = T[K];
}
1
2
3
4
5

而重映射并不是模板字符串类型的唯一伴生伙伴,为了迎接这位新成员,TS 还隆重地为它准备了一些特殊的工具类型,以此让它能够快速和各位前辈大哥平起平坐。

# 专用工具类型

这些工具类型专用于字符串字面量类型,包括 UppercaseLowercaseCapitalizeUncapitalize,看名字就能知道它们的作用:字符串大写、字符串小写、首字母大写与首字母小写:

type Heavy<T extends string> = `${Uppercase<T>}`;
type Respect<T extends string> = `${Capitalize<T>}`;

type HeavyName = Heavy<'linbudu'>; // "LINBUDU"
type RespectName = Respect<'linbudu'>; // "Linbudu"
1
2
3
4
5

上面的重映射部分,我们成功将键名从 name 修改成了 modified_name 的形式,如果要修改成我们更习惯的小驼峰形式呢?此时我们就可以使用上 Capitalize 工具类型了:

type CopyWithRename<T extends object> = {
  [K in keyof T as `modified${Capitalize<string & K>}`]: T[K];
};

// {
//   modifiedName: string;
//   modifiedAge: number;
// }
type CopiedFoo = CopyWithRename<Foo>;
1
2
3
4
5
6
7
8
9

实际上,这是 TypeScript 中首次引入了能直接改变类型本身含义的工具类型。你肯定对它们的内部实现非常有兴趣,然而当你跳转到源码定义时却会发现它们的定义是这样的:

type Uppercase<S extends string> = intrinsic;
type Lowercase<S extends string> = intrinsic;
type Capitalize<S extends string> = intrinsic;
type Uncapitalize<S extends string> = intrinsic;
1
2
3
4

intrinsic 代表了这一工具类型由 TypeScript 内部进行实现,如果我们去看内部的源码,会发现更神奇的部分:

function applyStringMapping(symbol: Symbol, str: string) {
  switch (intrinsicTypeKinds.get(symbol.escapedName as string)) {
    case IntrinsicTypeKind.Uppercase: return str.toUpperCase();
    case IntrinsicTypeKind.Lowercase: return str.toLowerCase();
    case IntrinsicTypeKind.Capitalize: return str.charAt(0).toUpperCase() + str.slice(1);
    case IntrinsicTypeKind.Uncapitalize: return str.charAt(0).toLowerCase() + str.slice(1);
  }
  return str;
}
1
2
3
4
5
6
7
8
9

你会发现,在这里字符串字面量类型被作为一个字符串值一样进行处理,这些工具类型通过调用了字符串的 toUpperCase 等原生方法实现。而按照这个趋势来看,在未来我们很有可能实现对字面量类型的更多操作,甚至以后我们能直接调用 Lodash 来处理字符串类型也说不定。

也正是由于目前这些实现需要在 TypeScript 内部实现,而无法通过类型编程达到,在类型编程范式归类中我们并没有包括这一部分。但模板字符串类型却可以和部分范式产生奇妙的化学反应,比如模式匹配工具类型。

# 模板字符串类型与模式匹配

模式匹配工具类型的核心理念就是对符合约束的某个类型结构,提取其某一个位置的类型,比如函数结构中的参数与返回值类型。而如果我们将一个字符串类型视为一个结构,就能够在其中也应用模式匹配相关的能力,而我们此前所缺少的就是模板字符串类型的能力。

模板插槽不仅可以声明一个占位的坑,也可以声明一个要提取的部分,我们来看一个例子:

type ReverseName<Str extends string> =
  Str extends `${infer First} ${infer Last}` ? `${Capitalize<Last>} ${First}` : Str;
1
2

我们一共在两处使用了模板字符串类型。首先是在约束部分,我们希望传入的字符串字面量类型是 "Tom Hardy" "Lin Budu" 这样的形式。注意,这里的空格也需要严格遵循,因为它也是一个字面量类型的一部分。对于符合这样约束的类型,我们使用模板插槽 + infer 关键字提取了其空格旁的两个部分(即名与姓)。然后在条件类型中,我们将 infer 提取出来的值,再次使用模板插槽注入到了新的字符串类型中。

来实际使用一下:

type ReversedTomHardy = ReverseName<'Tom hardy'>; // "Hardy Tom"
type ReversedLinbudu = ReverseName<'Budu Lin'>; // "Lin Budu"
1
2

你可能会想到,如果传入的字符串字面量类型中有多个空格呢?这种情况下,模式匹配将只会匹配首个空格,即 "A B C" 会被匹配为 "A""B C" 这样的两个结构:

type ReversedRes1 = ReverseName<'Budu Lin 599'>; // "Lin 599 Budu"
1

除了显式使用 infer 进行模式匹配操作以外,由于模板字符串的灵活性,我们甚至可以直接声明一个泛型来进行模式匹配操作:

declare function handler<Str extends string>(arg: `Guess who is ${Str}`): Str;

handler(`Guess who is Linbudu`); // "Linbudu"
handler(`Guess who is `); // ""
handler(`Guess who is  `); // " "

handler(`Guess who was`); // Error
handler(``); // Error
1
2
3
4
5
6
7
8

# 总结与预告

在这一节,我们学习了一个新的内置类型能力:模板字符串类型。它既是内置类型,也是内置类型工具,还包括了专用的工具类型等。在实际应用中,由于其灵活性与自动分发联合能力等能力,我们可以用它来进行大量字面量类型的定义与约束。另外,模板字符串类型本身也和此前已存在类型工具(如映射类型与索引类型)有着奇妙的组合效果。

而基于模板字符串类型与模式匹配,我们还可以进行非常多有趣的操作,在下一节我们就会来介绍一些基于模板字符串的工具类型,包括类型层面的 Split(从 1.2.4[1, 2, 4]),Join(从 [1, 2, 4]1.2.4),Trim(还有 TrimLeft、TrimRight),甚至还有 Case 转换,如驼峰 CamelCase 类型的一步步实现等。

# 扩展阅读

# 基于重映射的 PickByValueType

我们在这一节了解了重映射这一能力,它使得我们可以在映射类型中去修改映射后的键名,而如果映射后的键名变成了 never ,那么这个属性将不会出现在最终的接口结构中。也就是说,我们也可以基于重映射来实现结构处理工具类型,比如说 PickByValueType :

type PickByValueType<T extends object, Type> = {
  [K in keyof T as T[K] extends Type ? K : never]: T[K]
}
1
2
3

我们在重映射中再次进行了条件类型判断,并在其成立时才重映射到原键名,否则只返回一个 never。类似的,我们也可以实现 OmitByType 等等。

这也是 TypeScript 的更新中经常会出现的一个有趣现象,新版本的能力有时可以让我们大大简化类型编程中的操作,除了上面基于重映射实现的结构处理,我们此前也了解了基于 infer extends 来简化模式匹配类型中的结果过滤。

Last Updated: 10/9/2023, 5:43:25 PM