Yuzhe's Blog

yuzhes

Append to Object

Append to Object

Challenge Link

Challenge

Implement a type that adds a new field to the interface. The type takes three arguments. The output should be an object with the new field.

type Test = { id: '1' }
type Result = AppendToObject<Test, 'value', 4> // expected to be { id: '1', value: 4 }

Solution

type AppendToObject<T, U extends string, V> = {
  [K in keyof T | U]: K extends keyof T ? T[K] : V
}

The key idea: iterate over the union of existing keys and the new key, then decide each property’s value with a conditional type.

Breaking it down

Step 1 — Union the key sets: keyof T | U

keyof T gives us all the existing keys of T. U is the new key we want to add. Their union means the mapped type will cover every property — old ones and the new one.

Step 2 — Resolve the value for each key

K extends keyof T ? T[K] : V

Result: A flat object type containing all original properties plus the new U: V entry.

Why not just T & { [P in U]: V }?

The intersection approach works but produces an intersection type rather than a flat object:

type AppendToObject<T, U extends string, V> = T & { [P in U]: V }
// Result type: { id: '1' } & { value: 4 }

TypeScript will structurally treat this the same, but it displays as an intersection in IDE tooltips rather than a clean merged object. The mapped-type solution produces a single, flat object type that’s easier to read and reason about.

Deep Dive

The constraint U extends string

Object keys in TypeScript can be string, number, or symbol. Here we constrain U extends string to express that our new key must be a string literal type. Without this constraint, TypeScript would complain because keyof T | U requires a valid key type.

In practice this also lets TypeScript narrow U to a specific string literal (e.g., 'value'), which is what makes AppendToObject<Test, 'value', 4> resolve to { id: '1'; value: 4 } rather than { id: '1'; [x: string]: 4 }.

Overwriting existing keys

What if U is already a key in T? The conditional K extends keyof T ? T[K] : V would always take the first branch, keeping the original type — U would not be overwritten. If you want the new value to win (like Merge), flip the condition:

type AppendOrOverwrite<T, U extends string, V> = {
  [K in keyof T | U]: K extends U ? V : K extends keyof T ? T[K] : never
}

Relationship to Merge

AppendToObject is essentially a specialised Merge where the second “type” is a single-entry object { [U]: V }:

type AppendToObject<T, U extends string, V> = Merge<T, { [P in U]: V }>

Understanding Merge first makes AppendToObject trivially easy — they’re the same pattern.

Homomorphic vs non-homomorphic mapped types

The mapped type here is non-homomorphic: it doesn’t directly map over keyof T alone, so modifiers like readonly and ? from T are not preserved automatically.

type Source = { readonly id: string; name?: string }
type Result = AppendToObject<Source, 'age', number>
// { id: string; name: string | undefined; age: number }
// ^ readonly and ? are lost

To preserve them you’d need to split the mapped type into two homomorphic parts and intersect (or use the flat trick):

type AppendToObject<T, U extends string, V> =
  { [K in keyof T]: T[K] } & { [P in U]: V }

For most use cases (and for this challenge) the simple version is sufficient.

Key Takeaways