Advanced · 25 min
Project: a typed task reducer
Combine correlated actions, immutable updates, and exhaustive checking.
Update state without mutating it
A reducer returns the next state from the current state and a typed action. Here completion creates a new array and replaces the matching task. Readonly prevents writes through the array type, but does not deeply freeze task objects. The implementation avoids mutating them.
type Task = { id: number; title: string; done: boolean };
type Action = { type: "add"; task: Task } | { type: "complete"; id: number };
function reduceTasks(tasks: readonly Task[], action: Action): readonly Task[] {
switch (action.type) {
case "add": return tasks.some(task => task.id === action.task.id) ? tasks : [...tasks, action.task];
case "complete": return tasks.map(task => task.id === action.id ? { ...task, done: true } : task);
default: { const missing: never = action; return missing; }
}
}
const original: readonly Task[] = [{ id: 1, title: "Read", done: false }];
const updated = reduceTasks(original, { type: "complete", id: 1 });
console.log(original[0]?.done);
console.log(updated[0]?.done);Output
false true
Define edge-case behavior
This reducer ignores duplicate task IDs, and completing an unknown ID leaves task values unchanged. The never check exposes missing action cases during compilation. Actions in this example are trusted local values; network input would require runtime validation.
type Task = { id: number; title: string; done: boolean };
type Action = { type: "add"; task: Task } | { type: "complete"; id: number };
function reduceTasks(tasks: readonly Task[], action: Action): readonly Task[] {
switch (action.type) {
case "add": return tasks.some(task => task.id === action.task.id) ? tasks : [...tasks, action.task];
case "complete": return tasks.map(task => task.id === action.id ? { ...task, done: true } : task);
default: { const missing: never = action; return missing; }
}
}
const task: Task = { id: 1, title: "Read", done: false };
const once = reduceTasks([], { type: "add", task });
const twice = reduceTasks(once, { type: "add", task });
console.log(twice.length);Output
1
Put it into practice
- Predict the output and explain which relationships the types enforce.
- Fix the task while preserving its type contract, then check both practice questions.
Try it yourself
Code runs on this device. When you are signed in, drafts sync to your account.
Finish the typed reducer
Change the completion action to use the numeric ID 1. Preserve both actions, the reducer, and the original empty state.
The action union associates completion with a numeric task ID.
Show solution
type Task = { id: number; title: string; done: boolean };
type Action = { type: "add"; task: Task } | { type: "complete"; id: number };
function reduceTasks(tasks: readonly Task[], action: Action): readonly Task[] {
switch (action.type) {
case "add": return tasks.some(task => task.id === action.task.id) ? tasks : [...tasks, action.task];
case "complete": return tasks.map(task => task.id === action.id ? { ...task, done: true } : task);
default: { const missing: never = action; return missing; }
}
}
const initial: readonly Task[] = [];
const added = reduceTasks(initial, { type: "add", task: { id: 1, title: "Read", done: false } });
const finished = reduceTasks(added, { type: "complete", id: 1 });
const doneCount = finished.filter(task => task.done).length;
const originalCount = initial.length;
console.log(doneCount);
console.log(originalCount);The valid completion action selects the correct reducer branch. One task becomes done while the original empty array remains unchanged; the exhaustive check is preserved.