Yuzhe's Blog

yuzhes

Remove Index Signature

Remove Index Signature

Challenge Link

Challenge

Implement RemoveIndexSignature<T>, which excludes index signatures from object types.

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

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

Solution

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

The trick: filter out any key K where a broad primitive (string, number, or symbol) extends K. Only concrete named keys survive.

Deep Dive

What is an index signature?

An index signature is a catch-all key descriptor that says “this object can hold values at any key of this shape”:

type WithIndex = {
  [key: string]: any  // ← index signature
  foo(): void         // ← named property
}

Named properties and index signatures coexist in the same type. The challenge is to strip the signatures while keeping the concrete names.

The key insight: string extends K, not K extends string

This is the critical inversion. Let’s think about what each key K looks like:

Key typestring extends K?K extends string?
string (index sig)truetrue
'foo' (literal)falsetrue

Named string keys like 'foo' are string literals — they’re subtypes of string. But string is not a subtype of 'foo'. The index signature’s key type is the full string type, so string extends string is true, while string extends 'foo' is false.

By testing string extends K, we flip the direction: only index signatures (where K = string) will produce true. Literal keys produce false and survive the filter.

Mapped type remapping with as

TypeScript 4.1 introduced key remapping in mapped types via as:

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

When SomeFilter<K> resolves to never, the key is dropped entirely. We exploit this to build a filter:

type RemoveIndexSignature<T> = {
  [K in keyof T as string extends K ? never : K]: T[K]
  //               ^^^^^^^^^^^^^^^^
  //  "is K the string index signature?" → drop if yes
}

Why check all three: string, number, symbol?

TypeScript allows three kinds of index signatures:

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

We must filter all three. The full check:

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

Verifying with examples

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

type Result = RemoveIndexSignature<Foo>
// { foo(): void; bar: number }
// ✅ Index signature stripped, named properties preserved
type Mixed = {
  [idx: number]: string
  length: number
  push(item: string): void
}

type Result2 = RemoveIndexSignature<Mixed>
// { length: number; push(item: string): void }
// ✅ Numeric index stripped

Why PropertyKey extends K won’t work

You might think to use PropertyKey extends K:

// ❌ Doesn't work
type Bad<T> = {
  [K in keyof T as PropertyKey extends K ? never : K]: T[K]
}

PropertyKey is string | number | symbol. For this to be true, K would need to encompass all of PropertyKey, but even string doesn’t extend the union string | number | symbol in a way that would distinguish index sigs from literals. You must check each primitive individually.

The elegance of extends directionality

This challenge beautifully illustrates that extends in TypeScript is directional:

By checking string extends K instead of K extends string, we select only keys broad enough to be the index signature itself — not the concrete named keys.

Key Takeaways