TypeScript 类型体操(下)

Mar.01, 2025

几个我认为有趣的 TS 类型体操姿势:

递归

在 TS 的类型系统中,需要对元组中每个类型进行遍历处理时,往往要依靠递归的方式实现。

type Flatten<T> = T extends []
  ? [] 
  : T extends [infer First, ...infer Rest]
    ? [...Flatten<First>, ...Flatten<Rest>]
    : [T]

类似地,如果需要对字符串中的每一个字符进行处理:

type StrintToUnion<T extends string> = T extends `${infer Letter}${infer Rest}`
  ? Letter | StrintToUnion<Rest>
  : never

元组转对象

原题地址

对数组类型,使用索引访问T[number]将得到数组中所有元素构成的联合类型。

const arr = [1, 2, '3', true]
type T = (typeof arr)[number] // string | number | boolean

使用as const修饰数组时,将:

  • 数组中个元素的类型不会被扩展。即类型1不会被扩展为类型number,类型true也不会被扩展为类型boolean
  • 对象字面量获得只读属性;
  • 数组变为只读元组,即[1, '3', true] as const会变为readonly [1, '3', true]

此时,使用T[number]索引访问,将得到一个包含只读元组中所有字面量构成的类型。

const arr = [1, 2, '3', true] as const
type T = (typeof arr)[number] // 1 | 2 | '3' | true

利用这个特性,可以将元组转为对象:

type TupleToObject<T extends readonly any[]> = {
  [K in T[number]]: K
}

const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const

获取数组的第一个元素

TS 中也可以利用扩展运算符,“解构”数组类型。配合infer可以实现推断。

type First<T> = T extends [infer S, ...any] ? S : never

实现数组的includes方法

在类型系统中实现includes方法需要判断两个类型是否严格相等,方法如下:

type IsEqual<T, U> =
  (<G>() => G extends T ? 1 : 2) extends
  (<G>() => G extends U ? 1 : 2)
    ? true
    : false

这也是type-fest等 TS 类型工具库的实现方式。

然后,通过递归的方式实现Includes

type Includes<T extends any[], U> =
  IsEqual<T[0], U> extends true
    ? true
    : U extends [T[0], ...infer Rest]
      ? Includes<Rest, U>
      : false

可串联构造器

原题地址

Chainable类型允许串联,则意味着option方法必须返回一个Chainable类型;此外,最后返回的类型需要是经过串联之后完整的对象字面量,因此在若干次的option调用时,应当将最终返回的类型透传给下一次调用。可以利用泛型达到这一效果。

// 定义 T 为最终返回的对象字面量,并标识其为可选泛型参数
type Chainable<T = {}> = {
  option: <K extends string, V>(
    key: K extends keyof T ? never : K,
    val: V
  ) => Chainable<Omit<T, K> & Record<K, V>>
  get: () => T
}

Trim 和 Replace

对于类型Trim<T>,希望移除字符串收尾两端的空格,可以将空格声明为一个类型,结合模板和推导:

type Space = ' '
// 移除字符串首的空格
type TrimLeft<T extends string> = T extends `${Space}${infer R}` ? Trim<R> : T
// 移除字符串两端的空格
type Trim<T extends string> =
  T extends `${Space}${infer R}`
    ? Trim<R>
    : T extends `${infer L}${Space}`
      ? Trim<L>
      : T

类似地,可以实现ReplaceReplaceAll

type Replace<T extends string, From extends string, To extends string> = 
  From extends ''
    ? T
    : T extends `${infer L}${From}${infer R}`
      ? `${L}${To}${R}`
      : T

type ReplaceAll<T extends string, From extends string, To extends string> =
  From extends ''
    ? T
    : T extends `${infer L}${From}${infer R}`
      ? `${L}${To}${ReplaceAll<R, From, To>}`
      : T

增加函数参数

对于一个函数Fn,增加参数A到其参数的最后。

原题链接

type AppendArgument<Fn extends (...args: any) => any, A> =
  Fn extends (...args: [...infer Args]) => infer R
    ? (...args: [...Args, x: A]) => R
    : false

求字符串的长度

对于元组类型,使用T['length']可以获取元组的长度。但是对于字符串类型,S['length']的返回结果是number类型,而非字符串本身的长度。

可以使用递归的方式将字符串转为数组,然后获取数组的长度。

type StringToArray<S extends string> = S extends `${infer L}${infer R}`? [L, ...StringToArray<R>] : []
type LengthOfString<S extends string> = StringToArray<S>['length']

KebabCase

参考对字符串的字符遍历处理:

type KebabCase<S extends string> = S extends `${infer L}${infer R}`
  ? R extends Uncapitalize<R>
    ? `${Uncapitalize<L>}${KebabCase<R>}`
    : `${Uncapitalize<L>}-${KebabCase<R>}`
  : S

type Case1 = KebabCase<'fooBarBaz'>
  • 首先,infer L匹配第一个字符finfer R匹配剩余子串ooBarBaz。显然ooBarBazUncapitalize<'ooBarBaz'>相同,因此不进行处理,递归地对R子串分配KebabCase类型;
  • 在第二次类型分配时,infer L匹配oinfer R子串为oBarBaz。以此类推。
  • 当下一次递归分配类型时,infer L匹配oinfer RBarBaz。此时显然R extends Uncapitalize<R>应当进入 falsy 的分支,因此在oBarBaz之间插入连字符。
  • 在下一次递归时,infer LB,大写字母被处理为小写。R子串依照上面的规则进行下一步处理。
  • 最后,当递归调用至KebabCase<''>时,已经不满足${infer L}${infer R}的类型推断,因此返回S,递归终止。

任一真值的检测

原题地址

通过索引访问T[number],可以获得泛型参数中所有数组元素组成的联合类型:

type Members<T> = T[number]
type A = Members<[1, '2', false, [3], { 4: '5' }]>
// false | 1 | [3] | { 4: "5" } | "2"

结合extends的规则,当extends前面是一个泛型,且传入的泛型是联合类型时,则依次判断该联合类型中所有子类型能否分配给后面的类型。如果联合类型中所有子类型都可以被分配给的类型才视为真。

因此:

type FalsyType = 0 | '' | false | [] | {[key: string]: never} | null | undefined
type AnyOf<T extends readonly any[]> = T[number] extends FalsyType ? false : true

判断是否是某一类型

IsNever

首先需要明确never类型的特殊性。

  • 所有类型的子类型never可以被分配给任何类型;
  • 没有实例:没有属于never类型的值。

其次,单纯地依靠T extends never无法判断一个类型是否是never。回忆extends的规则,当T extends never时,泛型参数T触发分发机制,在分发时发现never是空的联合类型无法被拆解,因而直接返回never

因此,依靠将T包裹的方式,可以实现判断T是否是never:

type IsNever<T> = [T] extends [never] ? true : false

IsUnion

比较一个类型是否是联合类型的基本方式也是通过extends的分发机制实现。通过T extends any可以强制触发分发机制。此时如果T是联合类型,则会对T中的每一个子类型进行逐个处理。

如果T是联合类型,则意味着分发机制中,T不可以被赋给T的子类型。我们引入一个泛型U,使其等于原联合类型。

type IsUnion<T, U = T> = 
  // 处理 never 的特殊情况
  [T] extends [never] ? false :
  // 触发分发机制
  T extends any ?
    // 比较原始类型 U 和当前分发成员 T
    [U] extends [T] ? false : true
  : never;

IsTuple

元组包含特殊的性质。比较以下 A~F 各类型的length属性:

type A = []
type B = [number]
type C = [number, string]
type D = [1, 2]
const e = [1, 'hello'] as const
type F = number[]

type T1 = A['length'] // 0
type T2 = B['length'] // 1
type T3 = C['length'] // 2
type T4 = D['length'] // 2
type T5 = typeof e['length'] // 2
type T5 = F['length'] // number

不难发现:

  • 元组类型的length值即为构成元组的类型个数;
  • 数组类型的length值是number

因此,可以通过number extends T['length']完成判断。

type IsTuple<T> = [T] extends [never] ? false :
  T extends readonly any[]
    ? number extends T['length']
      ? false
      : true
    : false

是字符串字面量类型

已知Record<K, V>的特殊性质:

  • K是一个无限集时,例如Record<string, any>Kstring,此时所有的键都是可选的。这也是为什么声明const obj: Record<string, unknown> = {}时可以赋予空对象;
  • K是一个有限集时,例如Record<'a' | 'b', any>,此时所有的键都需要出现。这就是为什么const obj: Record<'name' | 'age', any> = {}会报告一个错误。

利用这条性质,可以通过{} extends Record<S, any>的方式判断S是否是有限集:

type IsFixedStringLiteralType<S extends string> = {} extends Record<S, 1>
  ? false
  : Equal<[S], S extends unknown ? [S] : never>

CC BY-NC-SA 4.0 © Owen 2025