# 112-大写首字母
# 题目描述
实现CapitalizeWords<T>
,它将字符串的每个单词的第一个字母转换为大写,其余部分保持原样。
例如
type capitalized = CapitalizeWords<'hello world, my friends'>; // 预期为 'Hello World, My Friends'
# 分析
一般碰到这种题目,比较简单的有几种思考方式:
第一种,先分词,再对每个词大写首字母即可。
第二种,遍历一次,当碰到字母,存储起来递归,当碰到空格或者其他非字母的字符时,将之前的存储的字符进行大写后,递归剩余字符。
第三种,遍历一次,首字母直接大写,后续每当碰到空格或者其他非字母的字符时,直接大写剩余字符(会有冗余操作),但是实际能够工作。
先尝试第一种思路,分词:
type CapitalizeWords<S extends string> =
// 以空格为界限,匹配空格前后的字符
S extends `${infer F}${' '}${infer R}`
? // 大写空格前后的字符后拼接
`${Capitalize<F>} ${Capitalize<CapitalizeWords<R>>}`
: // 如果没有匹配,大写后输出
Capitalize<S>;
type cases = [
Expect<Equal<CapitalizeWords<'foobar'>, 'Foobar'>>,
Expect<Equal<CapitalizeWords<'FOOBAR'>, 'FOOBAR'>>,
Expect<Equal<CapitalizeWords<'foo bar'>, 'Foo Bar'>>,
Expect<Equal<CapitalizeWords<'foo bar hello world'>, 'Foo Bar Hello World'>>,
// 无法通过
Expect<Equal<CapitalizeWords<'foo bar.hello,world'>, 'Foo Bar.Hello,World'>>,
// 无法通过
Expect<
Equal<
CapitalizeWords<'aa!bb@cc#dd$ee%ff^gg&hh*ii(jj)kk_ll+mm{nn}oo|pp🤣qq'>,
'Aa!Bb@Cc#Dd$Ee%Ff^Gg&Hh*Ii(Jj)Kk_Ll+Mm{Nn}Oo|Pp🤣Qq'
>
>,
Expect<Equal<CapitalizeWords<''>, ''>>,
];
由于这个分词,只分了空格,所以无法通过其他非空格导致的分词。
这时候可能会有同学尝试(比如我):
type CapitalizeWords<S extends string> =
// 以空格为界限,匹配空格前后的字符
S extends `${infer F}${' ' | ',' | '.'}${infer R}`
? // 大写空格前后的字符后拼接
`${Capitalize<F>} ${Capitalize<CapitalizeWords<R>>}`
: // 如果没有匹配,大写后输出
Capitalize<S>;
先不说不能匹配其他分词符,单单上述的匹配,就是不正确的,因为在 ts 的匹配中,对于 ${' ' | ',' | '.'}
也会走分发模式,最终产物也是联合类型,感兴趣的同学可以尝试下。
然后又有尝试了:
type CapitalizeWords<S extends string> =
// 以空格为界限,匹配空格前后的字符
S extends `${infer F}${infer M extends ' ' | ',' | '.'}${infer R}`
? // 大写空格前后的字符后拼接
`${Capitalize<F>} ${Capitalize<CapitalizeWords<R>>}`
: // 如果没有匹配,大写后输出
Capitalize<S>;
限制只占一个字符能行不?很遗憾,不能,在这种判定条件下, F 占一个坑位,M 占一个坑位,R 匹配剩余字符,而我们期望的是 F 能够匹配前面的整个字符。
到这一步,基本上第一种思路就走死了。这一种分词方法后续会 TODO: 完善一些,放在通用技巧里讲述
来看看第二种思路:
type CapitalizeRest<S extends string> =
// 遍历
S extends `${infer F}${infer R}`
? // 第一个字符如果是非字母,那么就大写剩余字符的首字母,并递归
`${F}${CapitalizeRest<
Uppercase<F> extends Lowercase<F> ? Capitalize<R> : R
>}`
: S;
// 始终大写首字母
type CapitalizeWords<S extends string> = Capitalize<CapitalizeRest<S>>;
这种思路还是比较巧妙的,Uppercase<F> extends Lowercase<F>
巧妙的判断了当前字符是否是非字母,虽然做了冗余的工作(不论 R 开头的是不是字母,都会做一次 Capitalize),但是能够实现。但是依旧通不过 🤣qq
这个用例,ts 会提示 Type instantiation is excessively deep and possibly infinite.
,具体原因就是递归超出限制,可以阅读 冷门-递归深度 了解相关限制。
接下来就最后一种思路了,
type CapitalizeWords<
S extends string,
// 辅助参数,存储匹配到非字母之前的字符
W extends string = '',
> = S extends `${infer A}${infer B}`
? // 判断当前字符是不是非字母
Uppercase<A> extends Lowercase<A>
? // 非字母,那么之前的字符该大写了,并递归剩余字符,重置 W
`${Capitalize<`${W}${A}`>}${CapitalizeWords<B>}`
: // 否则,递归剩余字符,并将当前字母加入存储中
CapitalizeWords<B, `${W}${A}`>
: // 将剩余字符大写后返回
Capitalize<W>;
这种思路最为中规中矩,但是在这个题目中能够通过所有用例,也不存在思路二中的一些冗余工作,可以说是比较完美的解法了,同时由于是尾递归,所以最大递归深度可以到达 1000 (递归深度可以查看: 冷门-递归深度) 这里。
# 题解
type CapitalizeWords<
S extends string,
// 辅助参数,存储匹配到非字母之前的字符
W extends string = '',
> = S extends `${infer A}${infer B}`
? // 判断当前字符是不是非字母
Uppercase<A> extends Lowercase<A>
? // 非字母,那么之前的字符该大写了,并递归剩余字符,重置 W
`${Capitalize<`${W}${A}`>}${CapitalizeWords<B>}`
: // 否则,递归剩余字符,并将当前字母加入存储中
CapitalizeWords<B, `${W}${A}`>
: // 将剩余字符大写后返回
Capitalize<W>;
# 知识点
- 字符遍历
Uppercase<A> extends Lowercase<A>
判断字符 A 是不是不是字母