ReplaceKeys
ReplaceKeys
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]
}
: neverDeep 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:
- The key might exist in the replacement map
Y— useY[K]. - The key might not exist in
Y(e.g., you pass{ aa: number }when replacingname) — usenever. - The key might not be in
Tat all — keep the original typeU[K]. - 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:
| Condition | Meaning | Result |
|---|---|---|
K extends T is false | Key is not being replaced | U[K] — keep original type |
K extends T is true AND K extends keyof Y is true | Key is in T and Y has a replacement | Y[K] — use new type |
K extends T is true AND K extends keyof Y is false | Key is in T but Y doesn't define it | never |
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
| Pattern | Effect |
|---|---|
T extends SomeType ? A : B | Distributes if T is a naked type parameter |
T extends any ? A : B | Always distributes (the "distribute trick") |
[T] extends [SomeType] ? A : B | No distribution (tuple wrapper) |
T extends never ? A : B | Evaluates to never for never input |
Key Takeaways
U extends anyis the canonical trick to force distribution over a union — each member is processed in isolation- Map over
keyof U, not overT— this preserves all original keys, replacing only the targeted ones - Three-way key decision: not in
T→ keep original; inTand inY→ useY[K]; inTbut not inY→never - Distributive conditional types are what make union-aware transformations possible in TypeScript
- This pattern is fundamental to "per-member" union manipulation — you'll see
U extends any ? { ... } : nevereverywhere in advanced type utilities
