# 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>;

# 知识点

  1. 字符遍历
  2. Uppercase<A> extends Lowercase<A> 判断字符 A 是不是不是字母
Last Updated: 2023/5/16 06:00:28