Typorithm · hard

8. Correlated unions and intersections

typescriptstable-typescriptinterviewindependent-practice
Saved on this device
Full lesson · 4 learning steps Open any exercise for focused practice
1

Step 1 of 4 · intro card

8. Correlated unions and intersections

0%

Concept and contract

Keep a selected operation tied to the payload it can actually consume.

An object with key: "title" | "count" and value: string | number permits mismatched pairs. A union of complete records can instead connect title to string and count to number. This correlation is essential when changes, commands or events travel together as data.

An intersection requires simultaneous capabilities, while a union represents alternatives. Choose based on the domain’s valid combinations. Adding optional fields to one large record often hides the relationship that a discriminated union could state directly.

Mechanism and worked example

The example applies a Change union by switching on key.

The example applies a Change union by switching on key. Each branch receives the matching value type and constructs a new Model. The compiler can safely connect the branch’s property name to its payload because they belong to the same narrowed union member.

Destructuring can preserve correlation in supported constant patterns, but mutation or separating values across unrelated parameters can lose it. Keep the tagged record intact at public boundaries when that makes both the implementation and the error messages easier to understand.

Complete worked example

typescript

type Model = { title: string; count: number };
type Change = { key: "title"; value: string } | { key: "count"; value: number };
function apply(model: Model, change: Change): Model {
  return change.key === "title" ? { ...model, title: change.value } : { ...model, count: change.value };
}
Reasoning
Branching on the tag preserves the payload type needed by the corresponding property.

Counterexamples and common mistakes

The rejected title change carries a number.

The rejected title change carries a number. A broad union-valued property would accept it and defer the failure to rendering or serialization. Do not repair this diagnostic by asserting the value to whichever type a particular assignment happens to require.

A generic indexed write can become difficult when the key itself is a union. The signature may permit independent choices that do not preserve a single concrete pairing. A mapped union of key-specific records is often clearer for stored commands and exhaustive dispatch.

Rejected counterexample: expected compiler diagnostic

typescript

type Change = { key: "title"; value: string } | { key: "count"; value: number };
// @ts-expect-error The key and payload do not correspond.
const invalid: Change = { key: "title", value: 3 };
Reasoning
A complete union member forbids the mismatched key and payload pair.

Independent practice

Create a SettingsChange for enabled:boolean and pageSize:number.

Create a SettingsChange for enabled:boolean and pageSize:number. Write a reducer that applies either change immutably. Begin by writing the two explicit union members before considering a mapped union helper. Keep the original settings object unchanged.

Test both branches, including false and zero as values, and reject a numeric enabled change. Optional extension: derive the union as a mapping indexed by keyof Settings, then compare its behavior with the explicit version rather than assuming they are equivalent.

Step 1: Write both valid key-value records. Step 2: Branch on key before applying value.

Practice starter: solve before reading the solution

typescript

type Settings = { enabled: boolean; pageSize: number };
type SettingsChange = { key: "enabled"; value: boolean } | { key: "pageSize"; value: number };
function reduce(settings: Settings, change: SettingsChange): Settings { throw new Error("TODO"); }
Reasoning
Use the explicit two-member union before abstracting a larger command model.
  • Hint 1: False and zero are valid payloads.
  • Hint 2: Keep source settings unchanged.

Solution, tests and trade-offs

The solution narrows the record’s key and writes the associated value without assertions.

The solution narrows the record’s key and writes the associated value without assertions. False and zero remain valid because the reducer branches on the discriminant rather than payload truthiness. The explicit version is a suitable final design for a small model.

Runtime tests establish the selected field changes and the other field stays intact. A negative assignment establishes the relationship statically. If you add a third setting, update the reducer exhaustively and decide whether a derived union reduces real duplication.

Practice solution and acceptance checks

typescript

type Settings = { enabled: boolean; pageSize: number };
type SettingsChange = { key: "enabled"; value: boolean } | { key: "pageSize"; value: number };
function reduce(settings: Settings, change: SettingsChange): Settings {
  return change.key === "enabled" ? { ...settings, enabled: change.value } : { ...settings, pageSize: change.value };
}

// Acceptance checks
const x={enabled:true,pageSize:10};if(reduce(x,{key:"enabled",value:false}).enabled||reduce(x,{key:"pageSize",value:0}).pageSize!==0||!x.enabled)throw new Error("correlation");
Reasoning
The reducer updates the chosen field while preserving valid false and zero values.

Interview reasoning and next step

In interviews, construct one invalid combination admitted by an object of unions, then show how a union of objects excludes it.

In interviews, construct one invalid combination admitted by an object of unions, then show how a union of objects excludes it. Explain why this matters to an event queue or persisted command, not merely to a single local property assignment.

Next, use satisfies and const assertions to check configuration without unnecessarily widening useful literal information. These tools help construct correlated registries while keeping the runtime object shape explicit.

Optional stretch — after the core task works: Derive the equivalent union from a mapped type.

Keep a selected operation tied to the payload it can actually consume.