TypeScript类型体操(二):模板字面量

本篇进入type-challenges的Medium难度的题目,其中有大量模板字面量匹配的题目,它们的套路基本都一样,并且难度友好,本篇将着重于这类题目。如果对模板字面量还不是很了解,可以看这里:TypeScript类型体操(一)

StartsWith

1
type StartsWith<T extends string, U extends string> = T extends `${U}${string}` ? true : false;

这个类型通过模板字面量匹配,判断T能否匹配${U}${string}​即可。

EndsWith

1
type EndsWith<T extends string, U extends string> = T extends `${string}${U}` ? true : false;

和StartsWith一样,字符串匹配即可。

Replace

1
2
3
4
5
type Replace<S extends string, From extends string, To extends string> = From extends ""
? S
: S extends `${infer A}${From}${infer B}`
? `${A}${To}${B}`
: S;

模板字面量匹配,需要先判断From​是否是空字符串,是的话就直接返回原始字符串了,因为空字符串放在S extends `${infer A}${From}${infer B}` ​始终里为true​。如果不是空字符串,就匹配模板${infer A}${From}${infer B}​,推断出要替换的字符串前面的部分和后面的部分为A​和B​,然后返回${A}${To}${B}​即可。

ReplaceAll

1
2
3
4
5
6
7
type ReplaceAll<S extends string, From extends string, To extends string> = From extends ""
? S
: S extends `${infer A}${From}${infer B}`
? B extends `${infer C}${From}${infer D}`
? `${A}${To}${ReplaceAll<`${C}${From}${D}`, From, To>}`
: `${A}${To}${B}`
: S;

和Replace一样,首先要判断一下From是否为空字符串,然后开始以${infer A}${From}${infer B}​进行模板字面量匹配,如果匹配为false,就直接返回S;如果匹配为true,那么还要判断推断出来的B是不是匹配${infer C}${From}${infer D}​,而不是拿${A}${To}${B}​去匹配${infer C}${From}${infer D}​,这是因为我们只需要替换掉原字符串里的From就好了,替换后再生成的不需要替换。

Capitalize

1
type MyCapitalize<S extends string> = S extends `${infer F}${infer R}` ? `${Capitalize<F>}${R}` : S;

传入一个字符串,让首字母变成大写,使用模板字面量匹配,推导出首字母和剩下的,把首字符Capitalize​一下即可。

Trim

Trim传入一个字符串,需要把字符串开头和结尾的空格、换行符(\n)、制表符(\t)给去掉,一开始可能会回想通过匹配模板字面量${" " | "\n" | "\t"}${infer R}${" " | "\n" | "\t"}​来实现,结果这种情况只适用于字符串两边都有要去掉的字符串,且数量一样的情况,否则就会漏掉一些无法去掉。

因此这里需要分类型,可以把上面这个字符串改成一个联合类型:${" " | "\n" | "\t"}${infer R}` | `${infer R}${" " | "\n" | "\t"}​,这样分别解决前后两段的匹配,如果匹配到了,那么把推导出来的R递归下去即可:

1
2
3
4
5
type Trim<S extends string> = S extends
| `${" " | "\n" | "\t"}${infer R}`
| `${infer R}${" " | "\n" | "\t"}`
? Trim<R>
: S;

Trim Right

和Trim同理:

1
type TrimRight<S extends string> = S extends `${infer R}${" " | "\n" | "\t"}` ? TrimRight<R> : S;

Trim Left

和Trim同理:

1
type TrimLeft<S extends string> = S extends `${" " | "\n" | "\t"}${infer R}` ? TrimLeft<R> : S;

Length of String

需要获取到字符串的长度,可能会想到直接这样:

1
type LengthOfString<S extends string> = S["length"];

这样其实不行,TypeScript中类型系统在编译时不会计算具体的字符串字面量的长度为一个可推断的类型,因为在编译时字符串的长度是不确定的,只有在运行时才确定,换句话说,上面的S["length"]只会是number,因为字符串的长度都是数字类型。因此我们需要把字符串转换为一个数组,通过再获取数组的长度来解决这个问题。

我们先实现一个字符串转数组的类型,就是把字符串里的首字符一个一个推导出来,构造一个数组,递归把首字符放进数组:

1
2
3
4
type StringToTuple<
S extends string,
R extends string[] = [],
> = S extends `${infer First}${infer Rest}` ? StringToTuple<Rest, [First, ...R]> : R;

然后获取一下数组的长度就好了。

1
type LengthOfString<S extends string> = StringToTuple<S>["length"];

完整代码:

1
2
3
4
5
6
type LengthOfString<S extends string> = StringToTuple<S>;

type StringToTuple<
S extends string,
R extends string[] = [],
> = S extends `${infer First}${infer Rest}` ? StringToTuple<Rest, [First, ...R]> : R;

也可以整合在一起:

1
2
3
4
type LengthOfString<
S extends string,
R extends string[] = [],
> = S extends `${infer First}${infer Rest}` ? LengthOfString<Rest, [First, ...R]> : R["length"];

String to Union

需要把字符串转换为每一个字符的联合类型,可以先把字符串转换为字符数组,直接用上一题的StringToTuple​,然后取索引访问类型即可:

1
2
3
4
5
6
type StringToTuple<
S extends string,
R extends string[] = [],
> = S extends `${infer First}${infer Rest}` ? StringToTuple<Rest, [First, ...R]> : R;

type StringToUnion<T extends string> = StringToTuple<T>[number];

Absolute

接收一个数字,然后取它的绝对值,返回字符串类型的绝对值。实例里传入的是任意类型的数字,可以是普通数字、字符串类型的数字、科学计数法、16进制、BigInt:

1
2
3
4
5
6
7
8
9
10
11
12
type cases = [
Expect<Equal<Absolute<0>, '0'>>,
Expect<Equal<Absolute<-0>, '0'>>,
Expect<Equal<Absolute<10>, '10'>>,
Expect<Equal<Absolute<-5>, '5'>>,
Expect<Equal<Absolute<'0'>, '0'>>,
Expect<Equal<Absolute<'-0'>, '0'>>,
Expect<Equal<Absolute<'10'>, '10'>>,
Expect<Equal<Absolute<'-5'>, '5'>>,
Expect<Equal<Absolute<-1_000_000n>, '1000000'>>,
Expect<Equal<Absolute<9_999n>, '9999'>>,
]

TypeScript是没法直接进行数字运算的,不过还好,将数字转换为字符串时,TypeScript的类型系统会直接把数字转换为常规的十进制数,因此我们只需要考虑传入的数字是不是负数就好了:

1
type Absolute<T extends number | string | bigint> = `${T}` extends `-${infer num}` ? num : `${T}`;

Percentage Parser

这个类型稍微有点复杂,需要匹配一个符合正则/^(\+|\-)?(\d*)?(\%)?$/​的字符串,并把三个捕获组里捕获到的字符串返回,这里要分情况讨论,一种情况是字符串里有%​,就是匹配${infer R}%​,如果匹配到,那么就再判断推导出的R​有没有+​或者-​,再分类返回;第二种情况是字符串里没有%​,就是匹配${infer S extends "+" | "-"}${infer N}​,如果匹配到了,就把推导出的S​和N​返回,没匹配到直接返回A即可:

1
2
3
4
5
6
7
type PercentageParser<A extends string> = A extends `${infer R}%`
? R extends `${infer S extends "+" | "-"}${infer N}`
? [S, N, "%"]
: ["", R, "%"]
: A extends `${infer S extends "+" | "-"}${infer N}`
? [S, N, ""]
: ["", A, ""];

DropChar

需要把一个字符串里的指定字符串给去掉,如果想偷懒的话,用上面写的ReplaceAll就可以解决,第三个参数写死为""​就好了:

1
2
3
4
5
6
7
8
9
10
11
type ReplaceAll<S extends string, From extends string, To extends string> = From extends ""
? S
: S extends `${infer A}${From}${infer B}`
? B extends `${infer C}${From}${infer D}`
? `${A}${To}${ReplaceAll<`${C}${From}${D}`, From, To>}`
: `${A}${To}${B}`
: S;

type DropChar<S extends string, C extends string> = ReplaceAll<S, C, "">;

type a = DropChar<"butter fly!", " ">;

还有传统的办法,就是匹配${infer First}${infer Rest}​,依次推导出首字符,判断首字符是不是C​,如果是C​,就递归剩下的部分,如果不是就递归返回${First}${DropChar<Rest, C>}​。

1
2
3
4
5
6
7
type DropChar<S extends string, C extends string> = S extends `${infer First}${infer Rest}`
? First extends C
? DropChar<Rest, C>
: `${First}${DropChar<Rest, C>}`
: S;

type a = DropChar<"butter fly!", " ">;