Yuzhe's Blog

yuzhes

ReplaceKeys

ReplaceKeys

Challenge Link

Challenge

Implement a type ReplaceKeys that replaces keys in union types. If some type in the union does not have the key, skip replacing it. The type takes three arguments.

type NodeA = {
  type: 'A'
  name: string
  flag: number
}

type NodeB = {
  type: 'B'
  id: number
  flag: number
}

type NodeC = {
  type: 'C'
  name: string
  flag: number
}

type Nodes = NodeA | NodeB | NodeC

type ReplacedNodes = ReplaceKeys<
  Nodes,
  'name' | 'flag',
  { name: number; flag: string }
>
// {type: 'A', name: number, flag: string}
// | {type: 'B', id: number, flag: string}
// | {type: 'C', name: number, flag: string}

type ReplacedNotExistKeys = ReplaceKeys<Nodes, 'name', { aa: number }>
// {type: 'A', name: never, flag: number}
// | NodeB
// | {type: 'C', name: never, flag: number}

Solution

type ReplaceKeys<U, T, Y> = U extends any
  ? {
      [K in keyof U]: K extends T
        ? K extends keyof Y
          ? Y[K]
          : never
        : U[K]
    }
  : never

Deep Dive

The Problem: Replacing Keys Across a Union

We have a union type U, a set of keys T (as a union of string literals), and a replacement map Y. For every member in the union, we want to walk its keys and replace any key that appears in T with the corresponding type from Y.

The tricky parts:

  1. The key might exist in the replacement map Y — use Y[K].
  2. The key might not exist in Y (e.g., you pass { aa: number } when replacing name) — use never.
  3. The key might not be in T at all — keep the original type U[K].
  4. Some union members might not even have the key in T — skip them untouched.

Distributing Over the Union

The core trick is U extends any. This looks like a no-op — every type extends any — but it triggers distributive conditional types.

When U is a union like NodeA | NodeB | NodeC, writing U extends any ? ... : ... causes TypeScript to split the union and apply the conditional to each member individually:

// Conceptually becomes:
(NodeA extends any ? Mapped<NodeA> : never)
| (NodeB extends any ? Mapped<NodeB> : never)
| (NodeC extends any ? Mapped<NodeC> : never)

This is exactly what we need — each member is processed independently, so keys not present in a member are naturally absent from the result.

The Mapped Type Inside

Once we’re operating on a single union member U (distributed), we map over its keys:

{
  [K in keyof U]: K extends T
    ? K extends keyof Y
      ? Y[K]
      : never
    : U[K]
}

Let’s trace through each branch:

ConditionMeaningResult
K extends T is falseKey is not being replacedU[K] — keep original type
K extends T is true AND K extends keyof Y is trueKey is in T and Y has a replacementY[K] — use new type
K extends T is true AND K extends keyof Y is falseKey is in T but Y doesn’t define itnever

The never case matches the challenge spec: ReplaceKeys<Nodes, 'name', { aa: number }> should give name: never because aa isn’t name.

Why Not Just Use { [K in T]: ... }?

You might think of mapping over T directly instead of keyof U. But that would lose all the keys not in T. We need to map over keyof U (all existing keys) and selectively replace only the ones in T.

The Distribution Is Critical

What if we didn’t use U extends any? Let’s say we just wrote:

// ❌ Without distribution
type ReplaceKeys<U, T, Y> = {
  [K in keyof U]: K extends T ? (K extends keyof Y ? Y[K] : never) : U[K]
}

For a union U = NodeA | NodeB | NodeC, keyof U would be the intersection of all keys — only the keys common to every member (type | flag). We’d lose member-specific keys like name and id.

With distribution, we map over keyof NodeA, keyof NodeB, and keyof NodeC independently, preserving each member’s full set of keys.

Union Distribution Cheat Sheet

PatternEffect
T extends SomeType ? A : BDistributes if T is a naked type parameter
T extends any ? A : BAlways distributes (the “distribute trick”)
[T] extends [SomeType] ? A : BNo distribution (tuple wrapper)
T extends never ? A : BEvaluates to never for never input

Key Takeaways