TS类型体操技巧总结

判断是否是 never 类型

typescript 复制代码
type IsNever<T> = [T] extends [never] ? true : false;
type IsNever<T> = (() => T) extends () => never ? true : false;

核心问题是 never extends never 返回是 never,使用元组或者函数包装一下都能正确判断。

typescript 复制代码
type IsNever<T> = T extends never ? true : false;
type X = IsNever<never>; // => never

判断一个类型是否为 unknown

错误做法:

typescript 复制代码
type IsUnknown<T> = T extends unknown ? true : false;
type X = IsUnknown<2>; // true

unknown 类型可以理解为任意类型的父类,top 类型,比 Object 还 top。

反过来 extends 就可以:

typescript 复制代码
type IsUnknown<T> = unknown extends T ? true : false;

和这个问题类似的还有判断一个类型是否为 number,如果要排除 number 字面量类型也应该反过来 extends

typescript 复制代码
type IsNumber<T> = number extends T ? true : false;
type X = IsNumber<2>; // false

判断类型相等

typescript 复制代码
type Equals<A, B> =
  (<T>() => T extends A ? 0 : 1) extends <T>() => T extends B ? 0 : 1 ? true : false;

多数人都会想到使用双向 extends 的方法,它能处理 子类 extends 父类 的情况,用元组包一下还能处理 never,但是处理不了 any

typescript 复制代码
type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;

type A = Equals<any, '1'>; // => true

更多细节建议移步:typescript 怎么判断两个类型相等?

表示键类型

TS 有内置类型 PropertyKey

typescript 复制代码
type PropertyKey = string | number | symbol;

表示一个键值对

麻烦的写法:

typescript 复制代码
type TupleToObject<T extends [any, any]> = {
  [K in T[0]]: T[1];
};

type X = TupleToObject<['name', 'ly']>; // => { name: "ly" }

可以直接用 Record

typescript 复制代码
type TupleToObject<T extends [any, any]> = Record<T[0], T[1]>;

交叉类型转接口类型

麻烦的写法:

typescript 复制代码
type IntersectionToInterface<I> = {
  [K in keyof I]: I[K];
};
type X = IntersectionToInterface<{ name: 'ly' } & { age: 27 }>; // => { name: "ly"; age: 27; }

简写:

typescript 复制代码
type IntersectionToInterface<I> = Omit<I, never>;

由此,可以可以把 Merge 类型写的非常简单:

typescript 复制代码
type IntersectionToInterface<I> = Omit<I, never>;
type Merge<A, B> = IntersectionToInterface<Omit<A, keyof B> & B>;

type A = {
  name: 'ly';
  age: 27;
};

type B = {
  name: 'YuTengjing';
  height: 170;
};

type C = Merge<A, B>;

/*
type C = {
    name: "YuTengjing";
    age: 27;
    height: 170;
}
*/

类型映射可以使用 as 对 key 进行过滤

例如不用内置的 Exclude 实现一个 MyOmit 类型:

typescript 复制代码
type MyOmit<T, L extends keyof T> = {
  [K in keyof T as K extends L ? never : K]: T[K];
};

type X = MyOmit<{ name: 'ly'; age: 27 }, 'name'>;
/*
type X = {
    age: 27;
}
*/

类型映射对数组类型也是适用的

曾经有段时间,我一直以为类型映射只能映射对象类型。

typescript 复制代码
type NumsToStrs<Arr extends readonly number[]> = {
  [K in keyof Arr]: `${Arr[K]}`;
};

type Strs = NumsToStrs<[1, 2, 3]>; // => type Strs = ["1", "2", "3"]

infer + extends

在模式匹配的时候可以使用 extends 来限定 infer 推断出的类型

模板字符串中匹配数字

typescript 复制代码
// TS 没有 NaN 字面量类型
type StringToNumber<S extends string> = S extends `${infer Num extends number}` ? Num : never;
type A = StringToNumber<''>; // => never
type B = StringToNumber<'1'>; // => 1
type C = StringToNumber<'1.2'>; // => 1

在对元组使用模式匹配时能正确识别成员类型

typescript 复制代码
type Count<
  Nums extends readonly number[],
  Num extends number,
  Result extends readonly unknown[] = [],
> = Nums extends [infer First, ...infer Rest extends readonly number[]]
  ? First extends Num
    ? Count<Rest, Num, [...Result, unknown]>
    : Count<Rest, Num, Result>
  : Result['length'];

type X = Count<[1, 2, 2, 3], 2>; // => type X = 2;

默认情况下 TS 推断上面 Rest 类型为 unknown[],可以使用 extends 让 TS 只匹配时 readonly number[] 的情况。

数组参数推断为元组

场景:我们要实现 Promise.all 方法的返回值推断

typescript 复制代码
declare function PromiseAll<T extends readonly any[]>(values: T): Promise<GetReturn<T>>;

type GetReturn<T extends readonly any[]> = T extends readonly [infer First, ...infer Rest]
  ? [Awaited<First>, ...GetReturn<Rest>]
  : T extends []
    ? []
    : T extends Array<infer E>
      ? Array<Awaited<E>>
      : [];
const R = PromiseAll([1, 2, 3]); // const R: Promise<number[]>

理想的推断结果为 Promise<1, 2, 3>。这里的核心问题在于,按照我们定义函数参数 values 的方式,目前这种方式定义推出的 values 是 number[] 类型。

解构一次

typescript 复制代码
declare function PromiseAll<T extends readonly any[]>(
  values: readonly [...T],
): Promise<GetReturn<T>>;
// => const R: Promise<[number, number, number]>

通过将类型参数 T 解构一次来提示 TS 编译器将 values 尽可能推断为元组,但是这种方式没法将 values 推断为字面量类型。

常量泛型参数

TS 5.0 引进的一个新语法:const Type Parameters,允许你对泛型参数标记为 const,这样 TS 在对函数参数推断时会直接将参数推断为字面类型:

typescript 复制代码
declare function PromiseAll<const T extends readonly any[]>(values: T): Promise<GetReturn<T>>;
const R = PromiseAll([1, 2, 3]);
// => const R: Promise<[1, 2, 3]>

递归

在 TS 体操中,递归的使用率非常高。递归按照用途可以分为两类:循环化为子问题

循环

这里的递归只是为了循环遍历每一个元素:

typescript 复制代码
type Join<Strs extends readonly string[], Result extends string = ''> = Strs extends [
  infer First extends string,
  ...infer Rest extends readonly string[],
]
  ? Join<Rest, `${Result}${First}`>
  : Result;

type X = Join<['a', 'b', 'c']>; // => 'abc'

化为子问题

这才是真正是用递归化大问题为小问题:

typescript 复制代码
type Join<Strs extends readonly string[]> = Strs extends [
  infer First extends string,
  ...infer Rest extends readonly string[],
]
  ? `${First}${Join<Rest>}`
  : '';

排列组合问题

实现排列组合的手段

  1. extends 左侧类型是 union 具有分配特性,有些文章翻译为分布式特性,我叫分配特性是因为它和乘法分配律很像
  2. 对元组使用 number 类型索引返回的是成员的 union
  3. 模板字符串参数为 union 会返回所有组合
  4. 在元组中对数组 union 解构,返回的是数组的 union
typescript 复制代码
// 1
type NumberToString<U extends number, E = U> = E extends U ? `${E}` : never;
type X = NumberToString<1 | 2 | 3>; // => type X = "1" | "2" | "3"

// 2
type Members = [1, 2, 3, 4][number]; // => type Members = 4 | 1 | 2 | 3

// 3
type S = `${'a' | 'b'}${'c' | 'd'}`; // type S = "ac" | "ad" | "bc" | "bd"

// 4
type Arr = [1, ...([2] | [3])]; // type Arr = [1, 2] | [1, 3]

灵活运用上面四个基本手段,可以解决大多数排列组合问题。

但是这些知识一些类型上的技巧,本质上解决一个类型体操问题还是需要找到问题的思路,多数体操问题可以用递归来解决问题。

实战解析

全排列

原题:permutation

递归思路:第一个坑位可以选取任意一个成员,然后对剩下的元素全排列,和第一个坑位组合的结果就是要的结果。

利用了手段 1 和 4,需要注意的是对 never 进行解构会导致整个数组返回 never 类型,这里 Exclude<U, E> 最后会是 never,所以最开始需要判断是否为 never

typescript 复制代码
type Permutation<U, E = U> = [U] extends [never]
  ? []
  : E extends U
    ? [E, ...Permutation<Exclude<U, E>>]
    : never;

type X = Permutation<'A' | 'B' | 'C'>;
// type X = ["A", "B", "C"] | ["A", "C", "B"] | ["B", "A", "C"] | ["B", "C", "A"] | ["C", "A", "B"] | ["C", "B", "A"]

不去重的组合

原题:combination

递归思路:组合要求至少有一个元素,那第一个坑位可以是任意一个成员,此时有两种选择,要和不和剩余元素组合,要么和剩下的元素的组合进行组合。

利用了手段 1, 2 和 3。

typescript 复制代码
type Comb<U extends string, E = U> = E extends U ? `${E}${` ${Comb<Exclude<U, E>>}` | ''}` : '';
type Combination<T extends readonly string[]> = Comb<T[number]>;
type X = Combination<['foo', 'bar', 'baz']>;
// => type X = "foo" | "bar" | "baz" | "bar baz" | "baz bar" | "foo bar" | "foo baz" | "foo bar baz" | "foo baz bar" | "baz foo" | "bar foo" | "bar foo baz" | "bar baz foo" | "baz foo bar" | "baz bar foo"

元组全排列

原题:permutations-of-tuple

测试用例:

typescript 复制代码
Expect<
  Equal<
    PermutationsOfTuple<[any, unknown, never]>,
    | [any, unknown, never]
    | [unknown, any, never]
    | [unknown, never, any]
    | [any, never, unknown]
    | [never, any, unknown]
    | [never, unknown, any]
  >
>;

这道题难点在于如果使用 number 索引元组返回得类型不对,你可能会想说先把数组 map 成每个成员被一个元组包围再去做全排列(也就是 [[any], [unknown], [never]]),我试过,很麻烦,而且还其它问题。所以这道题其实不适合用 number 去索引元组类型。

typescript 复制代码
type Insert<T extends readonly any[], U, E = T> = E extends T
  ? T extends [infer First, ...infer Rest]
    ? [U, ...T] | [First, ...Insert<Rest, U>]
    : [U]
  : never;

type PermutationsOfTuple<T extends unknown[]> = T extends [...infer Front, infer L]
  ? Insert<PermutationsOfTuple<Front>, L>
  : [];

递归思路:例如我们求 PermutationsOfTuple<[any, unknown, never]>,其实就是把最后的 never 想办法插入到 PermutationsOfTuple<[any, unknown]> 的结果中。

和这道题类似的还有另一道题:Transpose

typescript 复制代码
type cases = [
  Expect<Equal<Transpose<[]>, []>>,
  Expect<Equal<Transpose<[[1]]>, [[1]]>>,
  Expect<Equal<Transpose<[[1, 2]]>, [[1], [2]]>>,
  Expect<Equal<Transpose<[[1, 2], [3, 4]]>, [[1, 3], [2, 4]]>>,
  Expect<Equal<Transpose<[[1, 2, 3], [4, 5, 6]]>, [[1, 4], [2, 5], [3, 6]]>>,
  Expect<Equal<Transpose<[[1, 4], [2, 5], [3, 6]]>, [[1, 2, 3], [4, 5, 6]]>>,
  Expect<Equal<Transpose<[[1, 2, 3], [4, 5, 6], [7, 8, 9]]>, [[1, 4, 7], [2, 5, 8], [3, 6, 9]]>>,
];

其实问题就是将矩阵顺时针旋转 90 度,解法和上面类似,递归即可:

typescript 复制代码
type FallbackTo<T, Fallback> = T extends undefined ? Fallback : T;

type Insert<
  Arr extends ReadonlyArray<ReadonlyArray<number>>,
  Row extends readonly number[],
  Result extends ReadonlyArray<ReadonlyArray<number>> = [],
> = Result['length'] extends Row['length']
  ? Result
  : Insert<
      Arr,
      Row,
      [...Result, [...FallbackTo<Arr[Result['length']], []>, Row[Result['length']]]]
    >;

type Transpose<M extends number[][]> = M extends [
  ...infer Front extends number[][],
  infer Last extends number[],
]
  ? Insert<Transpose<Front>, Last>
  : [];

编码习惯

良好的编码习惯可以让你的代码更易于让别人和自己理解。

泛型参数命名

尽量取有意义的泛型参数名称,

  • 字符串我们就用 S
  • 数字可以用 Num 或者干脆 N
  • 字符串数组可以用 Strs,数字数组可以用 Nums
  • 元组的第一个成员用 First,最后一个成员用 Last,infer 出来的 spread 数组用 Rest
  • union 类型用 U
  • 成员是任意类型的数组可以用 Arr 或 List
  • 循环下标的数组可以用 Index
  • 结果可以用 Result 或者 Acc
  • 任意类型用 T
  • 任意的两个类型用 A 和 B

总之不要一股脑用 T,T1 和 T2 这种。

typescript 复制代码
type TrimStart<S extends string> = S extends `${infer First}${infer Rest}`
  ? First extends ' ' | '\t' | '\n'
    ? TrimStart<Rest>
    : `${First}${TrimStart<Rest>}`
  : '';

在对 union 进行映射时,如果直接 U extends U,后序无法访问 union U,U 此时表示成员,原本的 union 会被 shadow。使用另一个泛型参数 E 保存 U 就没有这个问题,E 表示 Element。 当然,如果是场景简单的情况也没必要这样暂存一下,但是如果一个类型代码比较多的时候这样做会更有利于编写。

typescript 复制代码
// bad
type NumsToStrs<U extends number> = U extends U ? `${U}` : never;

// good
type NumsToStrs<U extends number, E = U> = E extends U ? `${E}` : never;

泛型约束

泛型约束不仅是类型需求的一部分,也有助于理解类型。我们不但应该写泛型约束,还要遵循一定的最佳实践。

声明数组的时候尽量声明为只读的,因为声明为可写的数组那就不能接受只读的数组:

typescript 复制代码
type Length<Arr extends unknown[]> = Arr['length'];

const array = [1, 2] as const;

type X = Length<typeof array>;
/*
Type 'readonly [1, 2]' does not satisfy the constraint 'unknown[]'.
  The type 'readonly [1, 2]' is 'readonly' and cannot be assigned to the mutable type 'unknown[]'
 */

还有一个我思考过的问题:当你声明一个成员类型可以为任意类型的数组时,使用 readonly any[] 还是 readonly unknown[]?

大多数情况下两种声明方式都可以,少数情况下使用 readonly unknown[] 会达不到预期效果,具体案例我没印象了,等我想起来补一下。

长类型

TS 实现逻辑毕竟不如 JS 那么方便,有些时候我们一个类型要写很长代码,为了便于理解不至于写到一半看不懂之前写的是啥我们编写的时候要注意:

  1. 按照标准格式去缩进
  2. 必要时使用括号提高优先级
  3. 抽离中间类型,性能一般还会更好
  4. 适当增加辅助泛型参数

关于第一点,其实主要想说的是 extends 缩进:

typescript 复制代码
// ? 和 : 就不要写到一行,每次碰到 extends 就换行加缩进
A extends B
 ? true
 : false

关于第四点,还是拿之前的例子说明:

typescript 复制代码
type _NumsToStrs<U extends number, E = U> = E extends U ? `${E}` : never;

// 如果题目要求只能有一个泛型参数,那我们直接 alias 下就好了
type NumsToStrs<U extends number> = __NumsToStrs<U>;

分享几道精妙的体操题

Zip

这道题就是说给定两个元组 A 和 B,返回一个元组 C, 满足:C[index] = [A[index], B[index]]

typescript 复制代码
import type { Equal, Expect } from '@type-challenges/utils';

type cases = [
  Expect<Equal<Zip<[], []>, []>>,
  Expect<Equal<Zip<[1, 2], [true, false]>, [[1, true], [2, false]]>>,
  Expect<Equal<Zip<[1, 2, 3], ['1', '2']>, [[1, '1'], [2, '2']]>>,
  Expect<Equal<Zip<[], [1, 2, 3]>, []>>,
  Expect<Equal<Zip<[[1, 2]], [3]>, [[[1, 2], 3]]>>,
];

正常人的解法:

typescript 复制代码
type Zip<
  A extends readonly any[],
  B extends readonly any[],
  R extends readonly any[] = [],
  RL extends number = R['length'],
> = R['length'] extends A['length'] | B['length'] ? R : Zip<A, B, [...R, [A[RL], B[RL]]]>;

牛逼的解法,将模式匹配发挥到极致:

typescript 复制代码
type Zip<T extends readonly any[], U extends readonly any[]> = [T, U] extends [
  [infer TF, ...infer TR],
  [infer UF, ...infer UR],
]
  ? [[TF, UF], ...Zip<TR, UR>]
  : [];

Integer

这道题判断给定类型是否为整数。

typescript 复制代码
let x = 1;
let y = 1 as const;

type cases1 = [
  Expect<Equal<Integer<1>, 1>>,
  Expect<Equal<Integer<1.1>, never>>,
  Expect<Equal<Integer<1.0>, 1>>,
  Expect<Equal<Integer<1.0>, 1>>,
  Expect<Equal<Integer<0.5>, never>>,
  Expect<Equal<Integer<28.0>, 28>>,
  Expect<Equal<Integer<28.101>, never>>,
  Expect<Equal<Integer<typeof x>, never>>,
  Expect<Equal<Integer<typeof y>, 1>>,
];

正常人的解法

typescript 复制代码
type Integer<T extends number> = `${T}` extends `${infer Int extends number}.${string}`
  ? never
  : number extends T
    ? never
    : T;

神的解法:

typescript 复制代码
type Integer<T extends number> = `${T}` extends `${bigint}` ? T : never;

IsUnion

充分利用了 union 有多个成员的特性,非 union 可以理解为只有一个成员的 union

typescript 复制代码
type IsUnion<U, E = U> = [E extends U ? Exclude<U, E> : never] extends [never] ? false : true;

总结

typescript 的类型空间有很多刻意为之的设计,例如 union extends none-union 会采用分配律返回 union,但是 union extends union 又会采用判断是否为子集返回 true/false,这些设计其实都有其实际意义,也使得 typescript 类型非常灵活和强大。

在做类型体操时,我们不应该将一开始就将思路集中到用什么类型技巧,解决问题的思路是通用的,无论是 JS 还是 TS 类型空间,核心还是思路。例如解决类型空间的斐波那契数列,你首先要明确你的思路,是自底向上动态规划去循环,还是自顶向下递归,明确了使用动态规划,那我们就再用类型工具去将思路具象化,使用两个数组的 length 去表示迭代变量。

类型体操玩归玩,别上头,平时工作,为了赶进度有些时候还是可以上 // @ts-expect-error 的,不建议直接 any。当我们时间不是那么紧迫的时候,其实可以稍微再类型声明上多花点时间提高代码的可维护性和可靠性。例如 method 不要直接声明为 string,而是声明为 union,使用模板字符串限制 url 必须以 http 开头等。

本文算是最近刷类型体操的一些思考的集合,后序还会慢慢补充一些内容,例如 ThisType,利用方法重载解决类型问题等

最近我希望我会更新文章频繁一些,下一篇文章大概率是写前端工程化相关的。

最近还在找前端工作,这是我的在线简历,觉得合适的请邮件联系我。

相关推荐
范文杰3 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪3 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪3 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy4 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom4 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom4 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom4 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom4 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom5 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试
LaoZhangAI5 小时前
2025最全GPT-4o图像生成API指南:官方接口配置+15个实用提示词【保姆级教程】
前端