Yuzhe's Blog

yuzhes

Remove Index Signature

Remove Index Signature

题目链接

题目

实现 RemoveIndexSignature<T>,从对象类型中排除索引签名。

type Foo = {
  [key: string]: any
  foo(): void
}

type A = RemoveIndexSignature<Foo> // 期望 { foo(): void }

解法

type RemoveIndexSignature<T> = {
  [K in keyof T as string extends K
    ? never
    : number extends K
    ? never
    : symbol extends K
    ? never
    : K]: T[K]
}

核心技巧:过滤掉所有宽泛基础类型(stringnumbersymbol)能够 extends 的 key K。只有具体的具名 key 才能存活。

深入理解

什么是索引签名?

索引签名是一种”兜底”的 key 描述符,表示”这个对象可以存任意该形状的 key 下的值”:

type WithIndex = {
  [key: string]: any  // ← 索引签名
  foo(): void         // ← 具名属性
}

具名属性和索引签名在同一个类型里共存。难点在于:如何剥离签名,同时保留具体的属性名。

核心洞察:string extends K,而不是 K extends string

这是最关键的方向翻转。来想想每个 key K 长什么样:

Key 类型string extends KK extends string
string(索引签名)truetrue
'foo'(字面量)falsetrue

具名字符串 key 如 'foo'字符串字面量——它是 string 的子类型。但 string 不是 'foo' 的子类型。索引签名的 key 类型是完整的 string 类型,所以 string extends stringtrue,而 string extends 'foo'false

通过检测 string extends K,方向翻转:只有索引签名(K = string)才会产生 true,字面量 key 产生 false 并得以保留。

映射类型中的键重映射(as

TypeScript 4.1 引入了通过 as 对映射类型进行键重映射:

type Remap<T> = {
  [K in keyof T as SomeFilter<K>]: T[K]
}

SomeFilter<K> 解析为 never 时,该键会被完全丢弃。我们利用这一点构造过滤器:

type RemoveIndexSignature<T> = {
  [K in keyof T as string extends K ? never : K]: T[K]
  //               ^^^^^^^^^^^^^^^^
  //  "K 是 string 索引签名吗?" → 是则丢弃
}

为什么要同时检查三个:stringnumbersymbol

TypeScript 允许三种索引签名:

type AllIndexSigs = {
  [key: string]: any   // string 索引
  [idx: number]: any   // number 索引
  [sym: symbol]: any   // symbol 索引(TS 4.4+)
}

三种都需要过滤:

type RemoveIndexSignature<T> = {
  [K in keyof T as string extends K
    ? never
    : number extends K
    ? never
    : symbol extends K
    ? never
    : K]: T[K]
}

用例子验证

type Foo = {
  [key: string]: any
  foo(): void
  bar: number
}

type Result = RemoveIndexSignature<Foo>
// { foo(): void; bar: number }
// ✅ 索引签名被剔除,具名属性保留
type Mixed = {
  [idx: number]: string
  length: number
  push(item: string): void
}

type Result2 = RemoveIndexSignature<Mixed>
// { length: number; push(item: string): void }
// ✅ 数字索引被剔除

为什么 PropertyKey extends K 不行

你可能想用 PropertyKey extends K 一步到位:

// ❌ 不行
type Bad<T> = {
  [K in keyof T as PropertyKey extends K ? never : K]: T[K]
}

PropertyKeystring | number | symbol。要让这个为 trueK 必须能容纳整个 PropertyKey 联合,但即使是 string 也无法以区分索引签名和字面量 key 的方式 extends string | number | symbol。必须逐一检测每个基础类型。

extends 方向性的优雅

这道题精妙地展示了 TypeScript 中 extends 的方向性:

通过检测 string extends K 而不是 K extends string,我们只选出宽到足以成为索引签名本身的 key——而不是具体的具名 key。

核心要点