• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • TypeScript 类型体操通关秘籍

    • 1.如何阅读本小册
    • 2.为什么说 TypeScript 的火爆是必然?
    • 3.TypeScript 类型编程为什么被叫做类型体操?
    • 4.TypeScript 类型系统支持哪些类型和类型运算?
    • 5.套路一:模式匹配做提取
    • 6.套路二:重新构造做变换
    • 7.套路三:递归复用做循环
    • 8.套路四:数组长度做计数
    • 9.套路五:联合分散可简化
    • 10.套路六:特殊特性要记清
    • 11.类型体操顺口溜
    • 12.TypeScript 内置的高级类型有哪些?
    • 13.真实案例说明类型编程的意义
    • 14.类型编程综合实战一
    • 15.类型编程综合实战二
    • 16.新语法 infer extends 是如何简化类型编程的
    • 17.原理篇:逆变、协变、双向协变、不变
    • 18.原理篇:编译 ts 代码用 tsc 还是 babel?
    • 19.原理篇:实现简易 TypeScript 类型检查
    • 20.原理篇:如何阅读 TypeScript 源码
    • 21.原理篇:一些特殊情况的说明
    • 22.小册总结
    • 23.加餐:3 种类型来源和 3 种模块语法
    • 24.加餐:用 Project Reference 优化 tsc 编译性能
    • 25.加餐:一道 3 层的 ts 面试题
    • 26.加餐:项目中 2 个真实的类型编程案例
    • 27.加餐:TypeScript 新语法 satisfies:用声明 or 用推导?
    • 28.加餐:JSDoc 真能取代 TypeScript?
    • 29.加餐:一道字节面试真题

我们学会了 6 个类型体操的套路,各种高级类型都能写出来,也知道了类型体操的意义(类型之间有关联的时候必须要类型编程,用类型编程能做到更精准的类型提示和检查),但是做的练习还是不够多。

前面的案例更多是用于讲某个套路的,这节开始我们做一些比较综合的案例。

KebabCaseToCamelCase

常用的变量命名规范有两种,一种是 KebabCase,也就是 aaa-bbb-ccc 这种中划线分割的风格,另一种是 CamelCase, 也就是 aaaBbbCcc 这种除第一个单词外首字母大写的风格。

如果想实现 KebabCase 到 CamelCase 的转换,该怎么做呢?

比如从 guang-and-dong 转换成 guangAndDong。

这种明显是要做字符串字面量类型的提取和构造,并且因为单词数量不确定,要递归地处理。

所以是这样写:

type KebabCaseToCamelCase<Str extends string> =
    Str extends `${infer Item}-${infer Rest}`
        ? `${Item}${KebabCaseToCamelCase<Capitalize<Rest>>}`
        : Str;

类型参数 Str 是待处理的字符串类型,约束为 string。

通过模式匹配提取 Str 中 - 分隔的两部分,前面的部分放到 infer 声明的局部变量 Item 里,后面的放到 infer 声明的局部变量 Rest 里。

提取的第一个单词不大写,后面的字符串首字母大写,然后递归的这样处理,然后也就是 \`${Item}${KebabCaseToCamelCase<Capitalize<Rest>>。

如果模式匹配不满足,就返回 Str。

这样就完成了 KebabCase 到 CamelCase 的转换:

试一下

那反过来怎么转换呢?我们再实现下 CamelCase 到 KebabCase 的转换:

CamelCaseToKebabCase

同样是对字符串字面量类型的提取和构造,也需要递归处理,但是 CamelCase 没有 - 这种分割符,那怎么分割呢?

可以判断字母的大小写,用大写字母分割。

也就是这样:

type CamelCaseToKebabCase<Str extends string> =
    Str extends `${infer First}${infer Rest}`
        ? First extends Lowercase<First>
            ? `${First}${CamelCaseToKebabCase<Rest>}`
            : `-${Lowercase<First>}${CamelCaseToKebabCase<Rest>}`
        : Str;

类型参数 Str 为待处理的字符串类型。

通过模式匹配提取首个字符到 infer 声明的局部变量 First,剩下的放到 Rest。

判断下当前字符是否是小写,如果是的话就不需要转换,递归处理后续字符,也就是 \`${First}${CamelCaseToKebabCase<Rest>}。

如果是大写,那就找到了要分割的地方,转为 - 分割的形式,然后把 First 小写,后面的字符串递归的处理,也就是 \`-\${Lowercase<First>}${CamelCaseToKebabCase<Rest>}。

如果模式匹配不满足,就返回 Str。

这样就完成了 CamelCase 到 KebabCase 的转换:

试一下

做了两个字符串类型的练习,再来做个数组类型的:

Chunk

希望实现这样一个类型:

对数组做分组,比如 1、2、3、4、5 的数组,每两个为 1 组,那就可以分为 1、2 和 3、4 以及 5 这三个 Chunk。

这明显是对数组类型的提取和构造,元素数量不确定,需要递归的处理,并且还需要通过构造出的数组的 length 来作为 chunk 拆分的标志。

所以这个类型逻辑这么写:

type Chunk<
    Arr extends unknown[],
    ItemLen extends number,
    CurItem extends unknown[] = [],
    Res extends unknown[] = [],
> = Arr extends [infer First, ...infer Rest]
    ? CurItem["length"] extends ItemLen
        ? Chunk<Rest, ItemLen, [First], [...Res, CurItem]>
        : Chunk<Rest, ItemLen, [...CurItem, First], Res>
    : [...Res, CurItem];

类型参数 Arr 为待处理的数组类型,约束为 unknown。类型参数 ItemLen 是每个分组的长度。

后两个类型参数是用于保存中间结果的:类型参数 CurItem 是当前的分组,默认值 [],类型参数 Res 是结果数组,默认值 []。

通过模式匹配提取 Arr 中的首个元素到 infer 声明的局部变量 First 里,剩下的放到 Rest 里。

通过 CurItem 的 length 判断是否到了每个分组要求的长度 ItemLen:

如果到了,就把 CurItem 加到当前结果 Res 里,也就是 [...Res, CurItem],然后开启一个新分组,也就是 [First]。

如果没到,那就继续构造当前分组,也就是 [...CurItem, First],当前结果不变,也就是 Res。

这样递归的处理,直到不满足模式匹配,那就把当前 CurItem 也放到结果里返回,也就是 [...Res, CurItem]。

这样就完成了根据长度对数组分组的功能:

试一下

字符串类型、数组类型都做了一些练习,接下来再做个索引类型的:

TupleToNestedObject

我们希望实现这样一个功能:

根据数组类型,比如 [‘a’, ‘b’, ‘c’] 的元组类型,再加上值的类型 'xxx',构造出这样的索引类型:

{
    a: {
        b: {
            c: "xxx";
        }
    }
}

这个依然是提取、构造、递归,只不过是对数组类型做提取,构造索引类型,然后递归的这样一层层处理。

也就是这样的:

type TupleToNestedObject<Tuple extends unknown[], Value> = Tuple extends [
    infer First,
    ...infer Rest,
]
    ? {
          [Key in First as Key extends keyof any
              ? Key
              : never]: Rest extends unknown[]
              ? TupleToNestedObject<Rest, Value>
              : Value;
      }
    : Value;

类型参数 Tuple 为待处理的元组类型,元素类型任意,约束为 unknown[]。类型参数 Value 为值的类型。

通过模式匹配提取首个元素到 infer 声明的局部变量 First,剩下的放到 infer 声明的局部变量 Rest。

用提取出来的 First 作为 Key 构造新的索引类型,也就是 Key in First,值的类型为 Value,如果 Rest 还有元素的话就递归的构造下一层。

为什么后面还有个 as Key extends keyof any ? Key : never 的重映射呢?

因为比如 null、undefined 等类型是不能作为索引类型的 key 的,就需要做下过滤,如果是这些类型,就返回 never,否则返回当前 Key。

这里的 keyof any 在内置的高级类型那节也有讲到,就是取当前支持索引支持哪些类型的:

如果提取不出元素,那就构造结束了,返回 Value。

这样就实现了根据元组构造索引类型的功能:

当传入 number 时:

当传入 undefined 时:

试一下

我们再来练习下内置的高级类型,我们对这块的练习比较少:

PartialObjectPropByKeys

我们想实现这样一个功能:

把一个索引类型的某些 Key 转为 可选的,其余的 Key 不变,

比如

interface Dong {
    name: string;
    age: number;
    address: string;
}

把 name 和 age 变为可选之后就是这样的:

interface Dong2 {
    name?: string;
    age?: number;
    address: string;
}

这样的类型逻辑很容易想到是用映射类型的语法构造一个新的类型。

但是我们这里要求只用内置的高级类型来实现。

那要怎么做呢?

内置的高级类型里有很多处理映射类型的,比如 Pick 可以根据某些 Key 构造一个新的索引类型,Omit 可以删除某些 Key 构造一个新的索引类型,Partial 可以把索引类型的所有 Key 转为可选。

综合运用这些内置的高级类型就能实现我们的需求:

我们先把 name 和 age 这俩 Key 摘出来构造一个新的索引类型:

然后把剩下的 Key 摘出来构造一个新的索引类型:

把第一个索引类型转为 Partial,第二个索引类型不变,然后取交叉类型。

交叉类型会把同类型做合并,不同类型舍弃,所以结果就是我们需要的索引类型。

type PartialObjectPropByKeys<
    Obj extends Record<string, any>,
    Key extends keyof any,
> = Partial<Pick<Obj, Extract<keyof Obj, Key>>> & Omit<Obj, Key>;

类型参数 Obj 为待处理的索引类型,约束为 Record<string, any>。

类型参数 Key 为要转为可选的索引,那么类型自然是 string、number、symbol 中的类型,通过 keyof any 来约束更好一些。默认值是 Obj 的索引。

keyof any 是动态返回索引支持的类型,如果开启了 keyOfStringsOnly 的编译选项,那么返回的就是 string,否则就是 string | number | symbol 的联合类型,这样动态取的方式比写死更好。

Extract 是用于从 Obj 的所有索引 keyof Obj 里取出 Key 对应的索引的,这样能过滤掉一些 Obj 没有的索引。

从 Obj 中 Pick 出 Key 对应的索引构造成新的索引类型并转为 Partial 的,也就是 Partial<Pick<Obj,Extract<keyof Obj, Key>>>,其余的 Key 构造一个新的索引类型,也就是 Omit<Obj,Key>。然后两者取交叉就是我们需要的索引类型:

为啥这里没计算出最终的类型呢?

因为 ts 的类型只有在用到的的时候才会去计算,这里并不会去做计算。我们可以再做一层映射,当构造新的索引类型的时候,就会做计算了:

type Copy<Obj extends Record<string, any>> = {
    [Key in keyof Obj]: Obj[Key];
};

type PartialObjectPropByKeys<
    Obj extends Record<string, any>,
    Key extends keyof any = keyof Obj,
> = Copy<Partial<Pick<Obj, Extract<keyof Obj, Key>>> & Omit<Obj, Key>>;

这里的 Copy 就是通过映射类型的语法构造新的索引类型,key 和 value 都不变。

这样就会计算出最终的索引类型:

试一下

当然,这里的 Copy 也可以不加,并不影响功能。

总结

我们学完了类型编程的套路,也知道了类型编程的意义(类型有关联的时候必须用类型编程,类型编程可以实现更精准的类型提示和检查),但是做的综合一些的案例还是少,这节就各种类型的类型编程都做了一遍。

包括字符串类型、数组类型、索引类型的构造、提取,都涉及到了递归,也对内置的高级类型做了练习。

这一节的类型练下来,相信你会对类型编程会更加得心应手了。

本文案例的合并

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
13.真实案例说明类型编程的意义
Next
15.类型编程综合实战二