19.类型编程新范式:模板字符串工具类型进阶

10/9/2023

上一节,我们了解了模板字符串类型的基础内容,它与数个类型工具的协作,以及将作为本节核心内容的,模板字符串类型与模式匹配产生的化学反应

我们还是照例先复习一下,如何在模板插槽中使用 infer 关键字:

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

我们在上一节说到,对模板字符串类型中使用模式匹配时,本质上就是在一个字符串字面量类型结构做处理。因此我们可以复刻一个字符串类型的值拥有的大部分方法,从 trim 到 split,从 startsWith 到 endsWith 等等。这些方法就是我们本节要学习的内容,从简单的 trim 、includes,到需要稍微绕一绕的 split、join ,再到较为复杂的 case 转换,我们都将一一实现。

万事开头难并不是绝对的,也可能是你的开头不一定对。模板字符串相关的工具类型既有非常简单的,也有极度复杂烧脑的。为了秉持本小册一路循序渐进的优良作风,我们当然还是从最简单的部分开始。

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

# 从最简单的模式匹配说起:Trim、Includes 等

最简单的模式匹配只有一层条件类型语句,也就意味着我们不需要对模式匹配的结果做结构转换等操作。对比到字符串类型变量的方法,也就是 trim(trimLeft、trimRight)、includes、startsWith 与 endsWith。

我们从比较有代表性的 includes 看起,对应实现一个类型层面的版本:判断传入的字符串字面量类型中是否含有某个字符串

type Include<
  Str extends string,
  Search extends string
> = Str extends `${infer _R1}${Search}${infer _R2}` ? true : false;
1
2
3
4

在 Include 类型中,我们在 Search 前后声明了两个 infer 插槽,但实际上并不消费 R1 与 R2,而只是判断字符串是否可以被划分为要搜索的部分 + 其他部分。来验证一下实际效果:

type IncludeRes1 = Include<'linbudu', 'lin'>; // true
type IncludeRes2 = Include<'linbudu', '_lin'>; // false
type IncludeRes3 = Include<'linbudu', ''>; // true
type IncludeRes4 = Include<' ', ''>; // true
type IncludeRes5 = Include<'', ''>; // false
1
2
3
4
5

在 IncludeRes4 中,我们发现对于空字符串 '' 需要进行特殊的处理,''.includes('') 也应当是成立的,就像实际字符串中进行判断一样。我们希望尽可能贴近原本字符串方法的表现,因此我们需要新增额外处理:

type _Include<
  Str extends string,
  Search extends string
> = Str extends `${infer _R1}${Search}${infer _R2}` ? true : false;

type Include<Str extends string, Search extends string> = Str extends ''
  ? Search extends ''
    ? true
    : false
  : _Include<Str, Search>;
1
2
3
4
5
6
7
8
9
10

当字符串 Str 为空字符串时,我们判断 Search 是否是空字符串来直接决定返回结果,因为很明显 ''.includes('linbudu') 是不成立的。在 Str 不为空字符串时,我们才会真的进行 Include 的判断。

在 Str 与 Search 均为空字符串的情况下,我们直接返回 true,否则我们才进行模式匹配。

而提到模板字符串类型中的空字符串,我们会想到 trim 三兄弟:去除起始部分空格的 trimStart,去除结尾部分空格的 trimEnd,以及开头结尾空格一起去的 trim。基于模式匹配的思路我们还是很容易进行对应的类型实现:

// trimStart
type TrimLeft<V extends string> = V extends ` ${infer R}` ? R : V;

// trimEnd
type TrimRight<V extends string> = V extends `${infer R} ` ? R : V;

// trim
type Trim<V extends string> = TrimLeft<TrimRight<V>>;
1
2
3
4
5
6
7
8

聪明的你肯定会想到,我们的字符串边缘可能不止有一个空格!而这里的实现只能去掉一个,操作很简单,我们递归一下就好了:

type TrimLeft<Str extends string> = Str extends ` ${infer R}` ? TrimLeft<R> : Str;

type TrimRight<Str extends string> = Str extends `${infer R} ` ? TrimRight<R> : Str;

type Trim<Str extends string> = TrimLeft<TrimRight<Str>>;
1
2
3
4
5

这样,在字符串的两边不包含空格时,递归就会停止,从而返回一致“干净”的字符串。

而类型版本的 StartsWith 与 EndsWith 两个工具类型,和 Include 的实现非常接近,我们直接看其中 StartsWith 的最终实现与验证:

type _StartsWith<
  Str extends string,
  Search extends string
> = Str extends `${Search}${infer _R}` ? true : false;

type StartsWith<Str extends string, Search extends string> = Str extends ''
  ? Search extends ''
    ? true
    : _StartsWith<Str, Search>
  : _StartsWith<Str, Search>;

type StartsWithRes1 = StartsWith<'linbudu', 'lin'>; // true
type StartsWithRes2 = StartsWith<'linbudu', ''>; // true
type StartsWithRes3 = StartsWith<'linbudu', ' '>; // false
type StartsWithRes4 = StartsWith<'', ''>; // true
type StartsWithRes5 = StartsWith<' ', ''>; // true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

和 Include 基本一致,只是我们需要确保 Search 在字符串的开头部分。

在这一部分,我们了解了字符串类型中 Include、Trim 系列、StartsWith 与 EndsWith 这几个较简单的工具类型实现。现在热身完毕,是时候开始更复杂的部分了,比如 Replace 怎么样?

# 结构转换:Replace、Split 与 Join

看起来 Replace 好像是挺复杂的实现?但仔细想想它和 Include 其实没有啥区别,Include 判断是否能将字符串字面量划分为目标部分与其他部分,那 Replace 不是只需要将目标部分替换为新的部分,按照原本的结构组合好就行了吗?就像我们在对象层面的集合类型中学习的那样,一切复杂的工具类型最终都可以转换为数个简单工具类型的组合

在 Include 实现中,我们有两个纯做结构判断的 infer 插槽,现在它们也能真正的派上用场了:

export type Replace<
  Str extends string,
  Search extends string,
  Replacement extends string
> = Str extends `${infer Head}${Search}${infer Tail}`
  ? `${Head}${Replacement}${Tail}`
  : Str;
1
2
3
4
5
6
7

既然这两个插槽派上了用场,我们就需要给它们正式点的名字。Head 与 Tail 这两个名字我们后面还会常常见到,它们就表示开头与结尾的匹配部分。

这里我们其实是先判断字符串字面量中是否包含 Search 部分(就像 Include 那样),在包含也就是结构符合时,将匹配得到的 Head 与 Tail 部分夹上 Replacement,我们就实现了一个类型版本的 Replace:

// "林不渡也不是不能渡"
type ReplaceRes1 = Replace<'林不渡', '不', '不渡也不是不能'>;
// 不发生替换,仍然是"林不渡"
type ReplaceRes2 = Replace<'林不渡', '?', '??'>; //
1
2
3
4

然而,你应该遇到过需要全量替换的场景,也就是 ECMAScript 2021 的 replaceAll 方法。那我们能否在类型层面也实现一个 replaceAll?当然没问题,只需要再请出我们的老朋友——递归:

export type ReplaceAll<
  Str extends string,
  Search extends string,
  Replacement extends string
> = Str extends `${infer Head}${Search}${infer Tail}`
  ? ReplaceAll<`${Head}${Replacement}${Tail}`, Search, Replacement>
  : Str;
  
// "mmm.linbudu.top"
type ReplaceAllRes1 = ReplaceAll<'www.linbudu.top', 'w', 'm'>;
// "www-linbudu-top"
type ReplaceAllRes2 = ReplaceAll<'www.linbudu.top', '.', '-'>;
1
2
3
4
5
6
7
8
9
10
11
12

如果你更喜欢将这两个类型合并在一起,再通过选项来控制是否进行全量替换,其实也很简单,在结构工具类型中我们就试过引入类型层面的选项控制,这里也是类似:

export type Replace<
  Input extends string,
  Search extends string,
  Replacement extends string,
  ShouldReplaceAll extends boolean = false
> = Input extends `${infer Head}${Search}${infer Tail}`
  ? ShouldReplaceAll extends true
    ? Replace<
        `${Head}${Replacement}${Tail}`,
        Search,
        Replacement,
        ShouldReplaceAll
      >
    : `${Head}${Replacement}${Tail}`
  : Input;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

除了 replace 与 replaceAll,在字符串类型值中还有一个常用的方法:split ,它会将字符串按照确定的分隔符拆分成一个数组,比如从 'lin-bu-du' 按照 '-' 拆分为 ['lin', 'bu', 'du']。在类型层面,我们也可以实现 Split,毕竟“分隔符”这个词就在强烈暗示你,它一定是符合某种结构的字面量类型。比如最简单的,假设我们所有的字符串都是 "A-B-C" 这个结构,那就可以这么拆分:

export type Split<Str extends string> =
  Str extends `${infer Head}-${infer Body}-${infer Tail}`
    ? [Head, Body, Tail]
    : [];

type SplitRes1 = Split<'lin-bu-du'>; // ["lin", "bu", "du"]
1
2
3
4
5
6

当然,真实情况肯定不会这么简单,分隔符与字符串长度都是不确定的。但有着模式匹配与递归,没什么能难得倒我们,管你多长的字符串,我直接一个递归:

export type Split<
  Str extends string,
  Delimiter extends string
> = Str extends `${infer Head}${Delimiter}${infer Tail}`
  ? [Head, ...Split<Tail, Delimiter>]
  : Str extends Delimiter
  ? []
  : [Str];

// ["linbudu", "599", "fe"]
type SplitRes1 = Split<'linbudu,599,fe', ','>;

// ["linbudu", "599", "fe"]
type SplitRes2 = Split<'linbudu 599 fe', ' '>;

// ["l", "i", "n", "b", "u", "d", "u"]
type SplitRes3 = Split<'linbudu', ''>;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

这里有两种情况需要注意。第一种,存在多处分割时,Split 类型进行到最后一次,即无法再分割时,需要直接将最后一部分给返回。第二种,对于空字符串作为分隔符,其表现为将字符串字面量按字母进行拆分(SplitRes3),这同样与 Split 方法的实际表现一致。

在实际情况中,我们的字符串可能包含了多种可能的分隔符,即这里的 Delimiter 可以是一个联合类型 "_" | "-" | " " 。在这种情况下,模板字符串中的模式匹配也能够生效,它会使用这里的多个分隔符依次进行判断,并在判断到其中一种就立刻成立:

type Delimiters = '-' | '_' | ' ';

// ["lin", "bu", "du"]
type SplitRes4 = Split<'lin_bu_du', Delimiters>;
1
2
3
4

但需要注意的是,我们并不能在一个字符串中混用多种分隔符,在这种情况下由于联合类型在插槽中的排列组合特性,我们会得到一个诡异的结果:

// ["lin" | "lin_bu", "du"] | ["lin" | "lin_bu", "bu", "du"]
type SplitRes5 = Split<'lin_bu-du', Delimiters>;
1
2

实际上,每次只能依据一种分隔符进行拆分才是符合预期的。在正常的变量命名中,通常只会使用一种分隔方式,如 module-my_super_module-beta 这个命名中,实际上只有 - 是分隔符。确实使用了多种具有实际意义的分隔符时,我们应该进行多次拆分,如 CSS 的 BEM 命名方式(Block__Element--Modifier)下,我们经常会这么写类名:footer__button--danger。此时,我们就应当先按照 __ 拆出 Block,再按照 -- 拆出 Modifier。

另外,基于 Split 类型我们还可以获取字符串长度:

export type StrLength<T extends string> = Split<Trim<T>, ''>['length'];

type StrLengthRes1 = StrLength<'linbudu'>; // 7
type StrLengthRes2 = StrLength<'lin budu'>; // 8
type StrLengthRes3 = StrLength<''>; // 0
type StrLengthRes4 = StrLength<' '>; // 0
1
2
3
4
5
6

这是因为即使是在类型层面,元祖类型的长度也会是一个有实际意义的值。

我们上面介绍的许多方法之间其实存在关联,比如 TrimLeft 与 TrimEnd、StartsWith 与 EndsWith 是作用位置相反,Replace 是 Include 的进化版本,而 Split 也有这么一位伙伴:与它作用相反的 Join 。

Split 方法是将字符串按分隔符拆分成一个数组,而 Join 方法则是将一个数组中的所有字符串按照分隔符组装成一个字符串。我们只需要通过递归依次取出每一个字符串单元,使用模板插槽组装即可:

export type Join<
  List extends Array<string | number>,
  Delimiter extends string
> = List extends [string | number, ...infer Rest]
  ? // @ts-expect-error
    `${List[0]}${Delimiter}${Join<Rest, Delimiter>}`
  : string;
1
2
3
4
5
6
7

这里的 Rest 类型无法被正确地推导,因此使用了 // @ts-expect-error 来忽略错误。

看起来似乎没啥问题,我们来试一下?

// `lin-bu-du-${string}`
type JoinRes1 = Join<['lin', 'bu', 'du'], '-'>;
1
2

啊哦,很明显不对,我们分析一下原因。在递归进行到最后一次时,我们面对的条件类型大致是这样的:

export type JoinTmp = [] extends [string | number, ...infer Rest]
  ? // @ts-expect-error
    `lin-bu-du-${Join<Rest, Delimiter>}`
  : string;
1
2
3
4

这个条件很明显不会成立,因此它返回了 string 类型,而这个 string 类型我们的本义是用来兜底:如果 Join 无法拼接一个列表,那至少要返回一个 string 类型

要解决这种情况,我们只需要额外处理一下空数组的情况:

export type Join<
  List extends Array<string | number>,
  Delimiter extends string
> = List extends []
  ? ''
  : List extends [string | number, ...infer Rest]
  ? // @ts-expect-error
    `${List[0]}${Delimiter}${Join<Rest, Delimiter>}`
  : string;
1
2
3
4
5
6
7
8
9

但最终结果还是不太对:

// `lin-bu-du-`
type JoinRes2 = Join<['lin', 'bu', 'du'], '-'>;
1
2

实际上,在进行到最后一项数组成员时(即 ['du']),我们的递归过程就应当被提前阻止。这里产生一个多余的 '-' 的原因,其实就是让这仅有一项的数组还进行了一次分隔符拼接。

因此我们也需要处理只剩下最后一项的情况:

export type Join<
  List extends Array<string | number>,
  Delimiter extends string
> = List extends []
  ? ''
  : List extends [string | number]
  ? `${List[0]}`
  : List extends [string | number, ...infer Rest]
  ? // @ts-expect-error
    `${List[0]}${Delimiter}${Join<Rest, Delimiter>}`
  : string;

// "lin-bu-du"
type JoinRes3 = Join<['lin', 'bu', 'du'], '-'>;
1
2
3
4
5
6
7
8
9
10
11
12
13
14

看起来简单的 Join 类型,我们却连续实现了三次才完成。Split 类型其实也是,如果不提前考虑到各种情况,很难注意到在最后一次递归需要的特殊处理。这也是类型编程中常见的一个情景,一个工具类型有时需要多次改进、多种边界情况处理,才能称为“可用”,尤其是在递归的情况下

在模板字符串进阶类型的最后一部分,我们要来实现字符串的 Case 处理。这也是模板字符串类型中相对最为复杂的一部分,我们基本上是在对上面的模式匹配、递归、结构转换等概念做一次全面的结合应用。

# 最后一步:Case 转换

在上一节,我们已经了解了 TypeScript 内置的 Lowercase、Capitalize 等工具类型,知道它们是在内部实现的层面支持了字符串值的变换。其实基于这些工具类型,我们完全可以实现几乎所有常见的 Case,如 Camel Case('linBuDu')、Snake Case('lin_bu_du')、Delimiter Case(按照指定分隔符划分,如 'lin~bu~du' 'lin>bu>du' 等,也包括 Snake Case)。

首先需要明确的一点是,对于字符串,无论是值还是字面量类型,我们并没有办法去智能拆分,比如 mynameislinbudu,在不注入判断逻辑的情况下,计算机并不知道如何进行分词。如果是已经具有了一种 case 的字符串,比如 my_name_is_linbudu,此时我们要拆分就容易多了。拆分其实就是 Case 转换的基础,我们本节介绍的 Case 转换一定是建立在 传入字符串已经拥有了一种 case 的情况。

我们先以 CamelCase 为最终产物,了解如何从 SnakeCase 转换到 CamelCase,也就是下划线转小驼峰。

// 如何实现?
expectType<SnakeCase2CamelCase<'foo_bar_baz'>>('fooBarBaz');
1
2

看这清晰明确的结构,不用模式匹配简直暴殄天物,我们需要做的就是按照 _ 进行结构匹配,然后将除了首个字符串单元(在这里即是 foo )以外的后续部分都转为首字母大写。至于怎么转,当然是贴心内置的 Capitalize 了。

我们直接来看实现,由于这部分会有大量的结果验证,我们再次请出 expectType:

type SnakeCase2CamelCase<S extends string> =
  S extends `${infer Head}${'_'}${infer Rest}`
    ? `${Head}${SnakeCase2CamelCase<Capitalize<Rest>>}`
    : S;

expectType<SnakeCase2CamelCase<'foo_bar_baz'>>('fooBarBaz');
1
2
3
4
5
6

解决了 SnakeCase ,稍微举一反三,你会发现 KebabCase(中划线,如 "lin-bu-du")其实也解决了,不就是换个分隔符的事?

type KebabCase2CamelCase<S extends string> =
  S extends `${infer Head}${'-'}${infer Rest}`
    ? `${Head}${KebabCase2CamelCase<Capitalize<Rest>>}`
    : S;

expectType<KebabCase2CamelCase<'foo-bar-baz'>>('fooBarBaz');
1
2
3
4
5
6

SnakeCase 和 KebabCase 的唯一区别就是模式匹配的分隔符,身为封装工程师,我们肯定要把分隔符的能力进行抽象,支持任意的分隔符:

type DelimiterCase2CamelCase<
  S extends string,
  Delimiter extends string
> = S extends `${infer Head}${Delimiter}${infer Rest}`
  ? `${Head}${DelimiterCase2CamelCase<Capitalize<Rest>, Delimiter>}`
  : S;
1
2
3
4
5
6

来验证一下效果:

expectType<DelimiterCase2CamelCase<'foo-bar-baz', '-'>>('fooBarBaz');
expectType<DelimiterCase2CamelCase<'foo~bar~baz', '~'>>('fooBarBaz');
expectType<DelimiterCase2CamelCase<'foo bar baz', ' '>>('fooBarBaz');
1
2
3

到这里,我们支持了一个能够通过传入分隔符解决任意 Delimiter Case 转 Camel Case,看起来可以功成身退了。但这里还存在非常大的优化空间,比如我们还能让它自动处理分隔符。通常的变量命名只会使用 _- 作为分隔符,加上字面量中可能存在的空格,也就是我们希望自动处理 "_" | "-" | " " 这三个分隔符。

你可能会想当然地写出这样的代码:

type WordDelimiter = '-' | '_' | ' ';

type DelimiterCase2CamelCaseAuto<S extends string> =
  S extends `${infer Head}${infer Delimiter}${infer Rest}`
    ? Delimiter extends WordDelimiter
      ? `${Head}${DelimiterCase2CamelCaseAuto<Capitalize<Rest>>}`
      : S
    : S;
1
2
3
4
5
6
7
8

如果你真觉得这能够工作,我的建议是再回到上一部分重新来过。对于这种连续的 infer 插槽,其匹配策略是尽可能为前面的每个插槽匹配一个字符,然后将所有剩下的部分都交给最后一个插槽。如 "lin-bu-du" 在上面会匹配为 l i n-budu

因此要实现一个自动分割的版本,我们还需要一些额外的工作,但思路仍然是一致的:按照分隔符拆分,对除首个字符串以外的字符单元进行首字母大写处理以及组装。在 Delimiter Case 中,我们通过可确定的分隔符直接使用递归模式匹配拆分,如果分隔符并不确定的情况下我们应该怎么做?

我们在上面讲到的 Split 类型,其实就能很好地满足我们的需要:

type Delimiters = '-' | '_' | ' ';

// ["lin", "bu", "du"]
type SplitRes4 = Split<'lin_bu_du', Delimiters>;
1
2
3
4

也就是说,我们可以使用 Split 将字符串拆分成数组,然后在数组中去处理第一项以外的其他成员:

export type CamelCase<K extends string> = CamelCaseStringArray<
  Split<K, Delimiters>
>;
1
2
3

而 CamelCaseStringArray 这个类型,我们希望它能够将 ['lin', 'bu', 'du'] 转化为 ['lin', 'Bu', 'Du']。也就是说这个数组可以分为两个部分,无需处理的第一项和全部首字母大写的其余项:

type CamelCaseStringArray<Words extends string[]> = Words extends [
  `${infer First}`,
  ...infer Rest
]
  ? `${First}${CapitalizeStringArray<Rest>}`
  : never;
1
2
3
4
5
6

在数组中进行模式匹配时,我们为何也使用了看似多余的 infer 插槽?这是因为我们的 First 会直接传入给插槽,通过 infer 插槽匹配,能够确保最终 infer First 得到的 infer 值一定会是字符串类型。

由于这里的 First 和 Rest 被视为两种不同的结构,因此我们需要再声明一个 CapitalizeStringArray 类型,它的作用就是将递归地将数组中所有的字符串单元转化为首字母大写形式

type CapitalizeStringArray<Words extends any[]> = Words extends [
  `${infer First}`,
  ...infer Rest
]
  ? `${Capitalize<First>}${CapitalizeStringArray<Rest>}`
  : '';
1
2
3
4
5
6

这样我们就得到了一个初具雏形的 Camel Case 智能版:

type Delimiters = '-' | '_' | ' ';

type CapitalizeStringArray<Words extends any[]> = Words extends [
  `${infer First}`,
  ...infer Rest
]
  ? `${Capitalize<First>}${CapitalizeStringArray<Rest>}`
  : '';

type CamelCaseStringArray<Words extends string[]> = Words extends [
  `${infer First}`,
  ...infer Rest
]
  ? `${First}${CapitalizeStringArray<Rest>}`
  : never;

export type Split<
  S extends string,
  Delimiter extends string
> = S extends `${infer Head}${Delimiter}${infer Tail}`
  ? [Head, ...Split<Tail, Delimiter>]
  : S extends Delimiter
  ? []
  : [S];

type CamelCase<K extends string> = CamelCaseStringArray<
  Split<K, Delimiters>
>;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

来验证一下效果:

expectType<CamelCase<'foo-bar-baz'>>('fooBarBaz');
expectType<CamelCase<'foo bar baz'>>('fooBarBaz');
expectType<CamelCase<'foo_bar_baz'>>('fooBarBaz');
1
2
3

CamelCase 这个类型确实有一定复杂度,但它本质上仍然是数个基础工具类型与概念的组合,包括模板字符串类型、infer 插槽与模式匹配结合、Rest infer 等等。同时,我们并没有想一口气把它实现出来,而是先整理了思路(拆分、转换、重组),确定了能够依赖的基础工具类型(Split),才一步步实现了它。

这里的 Camel Case 其实还有一些需要改进的地方,比如首字母大写的 Foo-bar-baz 和全大写的 'FOO-BAR-BAZ' ,也需要转化为小驼峰形式的 fooBarBaz

这里我放上 Type Fest 中 Camel Case 的最终实现,基本上处理了绝大部分的边界情况:

export type PlainObjectType = Record<string, any>;

export type WordSeparators = '-' | '_' | ' ';

export type Split<
  S extends string,
  Delimiter extends string
> = S extends `${infer Head}${Delimiter}${infer Tail}`
  ? [Head, ...Split<Tail, Delimiter>]
  : S extends Delimiter
  ? []
  : [S];

type CapitalizeStringArray<Words extends readonly any[], Prev> = Words extends [
  `${infer First}`,
  ...infer Rest
]
  ? First extends undefined
    ? ''
    : First extends ''
    ? CapitalizeStringArray<Rest, Prev>
    : `${Prev extends '' ? First : Capitalize<First>}${CapitalizeStringArray<
        Rest,
        First
      >}`
  : '';

type CamelCaseStringArray<Words extends readonly string[]> = Words extends [
  `${infer First}`,
  ...infer Rest
]
  ? Uncapitalize<`${First}${CapitalizeStringArray<Rest, First>}`>
  : never;

export type CamelCase<K extends string> = CamelCaseStringArray<
  Split<K extends Uppercase<K> ? Lowercase<K> : K, WordSeparators>
>;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

另外,虽然 Camel Case 只是对一维字符串字面量进行的转换,但由于我们上一节讲到的重映射能力,它也可以被应用到对象类型层面:

export type CamelCasedProperties<T extends PlainObjectType> = {
  [K in keyof T as CamelCase<string & K>]: T[K] extends object
    ? CamelCasedProperties<T[K]>
    : T[K];
};

expectType<
  CamelCasedProperties<{ foo_bar: string; foo_baz: { nested_foo: string } }>
>({
  fooBar: '',
  fooBaz: {
    nestedFoo: '',
  },
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14

基于此,我们就完成了模板字符串工具类型的最后一步,也是其集大成者 Case 转换。除了 Camel Case 以外,其实你也可以很容易对应着实现智能版的 Delimiter Case、Snake Case 等等,只要按着思路划分、基础工具类型确定、边界情况补全这一系列路径走下来,看似繁琐的模板字符串工具类型也并不可怕。

# 总结与预告

这一节,我们完成了模板字符串类型的进阶学习,仿照着 JavaScript 中字符串变量的方法实现了 Trim、Include、Replace、Split 以及 Case 转换等工具类型。这些类型虽然在实际项目开发中使用场景有限,但却带来了访问性修饰与结构处理等类型编程范式以外的新类型编程体系。同时,我们借着模板字符串类型的灵活性,再次复习了模式匹配的应用场景,让你对它的应用有了更深刻的了解。

到这里,我们的类型能力核心篇章就告一段落了。在这数十节的内容里,我们从内置类型基础开始,一步步跨过了内置类型工具、类型系统、类型编程与模板字符串类型四座大山,现在你可以自信地说自己已经把 TypeScript 的类型能力掌握个八九不离十了。

接下来,我们就要迈入到实战环节了,包括类型声明、React 与 ESLint 中的工程实践、装饰器、TSConfig 配置、Node API 开发等等,都是我们将攻克的对象。但是,类型能力和工程实战毕竟是两个基本独立的部分,因此我更建议你在此稍微驻足,做一个阶段性总结,看看是否已经把类型能力概念都掌握了?

相比之下,实战环节的难度其实要更低,我们更多是在介绍语法、配置项、实际使用,所以你完全可以好好缓解一下被类型折磨的大脑。

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