你了解过模板字面量类型么?你想知道如何利用模板字面量类型,来减少 TypeScript 项目中的重复代码么?如果想的话,阅读完本文内容之后,也许你就懂了。
假设我们想要定义一种类型来描述 CSS padding 规则,如果你了解 TypeScript 类型别名和联合类型的话,能很容易定义出 CssPadding 类型。
type CssPadding =
| "padding-left"
| "padding-right"
| "padding-top"
| "padding-bottom";
但如果我们想要继续定义一种新的类型来描述 CSS margin 规则,你是不是立马想到与定义 CssPadding 类型一样的方式。
type MarginPadding =
| "margin-left"
| "margin-right"
| "margin-top"
| "margin-bottom";
对于以上定义的两种类型来说,虽然它们都能满足我们的需求。但在定义这两种类型的过程中,仍然存在一些重复的代码。
那么如何解决这个问题呢?这时我们可以使用 TypeScript 4.1 版本引入了新的模板字面量类型,具体的使用方式如下:
type Direction = "left" | "right" | "top" | "bottom";
type CssPadding = `padding-$ Direction `;
type MarginPadding = `margin-$ Direction `;
看完以上代码,是不是觉得简洁很多。与 JavaScript 中的模板字符串类似,模板字面量类型被括在反引号中,同时可以包含 ${T} 形式的占位符,其中类型变量 T 的类型可以是 string、number、boolean 或 bigint 类型。
模板字面量类型不仅为我们提供了连接字符串字面量的能力,而且还可以把非字符串基本类型的字面量转换为对应的字符串字面量类型。下面我们来举一些具体的例子:
type EventName<T extends string> = `$T Changed`;
type Concat<S1 extends string, S2 extends string> = `$ S1 -$ S2 `;
type ToString<T extends string | number | boolean | bigint> = `$ T `;
type T0 = EventName<"foo">; // 'fooChanged'
type T1 = Concat<"Hello", "World">; // 'Hello-World'
type T2 = ToString<"阿宝哥" | 666 | true | -1234n>; // "阿宝哥" | "true" | "666" | "-1234"
对于上述的例子来说,其实并不复杂。但现在问题来了,如果传入 EventName 或 Concat 工具类型的实际类型是联合类型的话,那么结果又会是怎样呢?接下来,我们来验证一下:
type T3 = EventName<"foo" | "bar" | "baz">;
// "fooChanged" | "barChanged" | "bazChanged"
type T4 = Concat<"top" | "bottom", "left" | "right">;
// "top-left" | "top-right" | "bottom-left" | "bottom-right"
为什么会生成这样的类型呢?这是因为对于模板字面量类型来说,当类型占位符的实际类型是联合类型(A |B |C)的话,就会被自动展开:
`$ A|B|C ` => ` $ A ` | ` $ B ` | ` $ C `
而对于包含多个类型占位符的情形,比如 Concat 工具类型。多个占位符中的联合类型解析为叉积:
`$A|B -$ C|D ` => `$ A -$ C ` | `$ A -$ D ` | `$ B -$ C ` | `$ B -$ D `
了解完上述的运算规则,你应该就能理解生成的 T3 和 T4 类型了。
在使用模板字面量类型的过程中,我们还可以使用 TypeScript 提供的,用于处理字符串类型的内置工具类型,比如 Uppercase、Lowercase、Capitalize 和 Uncapitalize。具体的使用方式是这样的:
type GetterName<T extends string> = `get$Capitalize<T> `;
type Cases<T extends string> = `$ Uppercase<T> $ Lowercase<T> $ Capitalize<T> $ Uncapitalize<T> `;
type T5 = GetterName<'foo'>; // "getFoo"
type T6 = Cases<'bar'>; // "BAR bar Bar bar"
其实,模板字面量类型的能力是很强大的,结合 TypeScript 的条件类型和 infer 关键字我们还可以实现类型推断。
type Direction = "left" | "right" | "top" | "bottom";
type InferRoot<T> = T extends `${infer R}${Capitalize<Direction>}` ? R T;
type T7 = InferRoot<"marginRight">; // "margin"
type T8 = InferRoot<"paddingLeft">; // "padding"
在以上代码中,InferRoot 工具类型除了利用模板字面量类型之外,还使用了 TypeScript 条件类型和 infer。如果你对这两个知识点,还不了解的话,可以观看 “用了 TS 条件类型,同事直呼 YYDS” 和 “学会 TS infer,写起泛型真香!” 这两篇文章。
此外,TypeScript 4.1 版本允许我们使用 as 子句对映射类型中的键进行重新映射。它的语法如下:
type MappedTypeWithNewKeys<T> =
[K in keyof T as NewKeyType] T K
// ^^^^^^^^^^^^^
// This is the new syntax!
其中 NewKeyType 的类型必须是 string | number | symbol 联合类型的子类型。在重新映射的过程中,结合模板字面量类型所提供的能力,我们就可以实现一些有用的工具类型。
比如,我们可以定义一个 Getters 工具类型,用于为对象类型生成对应的 Getter 类型:
type Getters<T> =
[K in keyof T as `get${Capitalize<string & K>}`] () => T K
;
interface Person
name string;
age number;
location string;
type LazyPerson = Getters<Person>;
//
// getName () => string;
// getAge () => number;
// getLocation () => string;
//
在以上代码中,因为 keyof T 返回的类型可能会包含 symbol 类型,而 Capitalize 工具类型要求处理的类型需要是 string 类型的子类型,所以需要通过交叉运算符进行类型过滤。
除了实现简单的工具类型之外,我们还可以实现比较复杂的工具类型。比如,用于获取对象类型中,任意层级属性的类型。
type PropType<T, Path extends string> = string extends Path
? unknown
Path extends keyof T
? T Path
Path extends `$ infer K .$ infer R `
? K extends keyof T
? PropType<T K , R>
unknown
unknown;
declare function getPropValue<T, P extends string>(
obj T,
path P
) PropType<T, P>;
const obj = { a b c666 d"阿宝哥" ;
let a = getPropValue(obj, "a"); // { b c number, d string
let ab = getPropValue(obj, "a.b"); // {c number, d string
let abd = getPropValue(obj, "a.b.d"); // string
在以上代码中,PropType 工具类型涉及 TypeScript 中的多个核心知识点。