Syncing account progress…

Back

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

  1. Predict the output and explain which relationships the types enforce.
  2. 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.

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.

Practice