Learn TypeFirst · Part 1

Immutability

TypeFirst · AgileLabs Engineering · 6 min read

Welcome to Learn TypeFirst — a hands-on series that takes you from "I use TypeScript" to "I think in types." Two course rules apply from day one: any, as, is and ! are banned, and every function gets a full type signature. We start with the habit underneath everything else: stop mutating data.

Why mutation hurts

A mutated value changes for everyone holding a reference to it — including code far away that never asked. That's what "spooky action at a distance" means in practice: a list renders wrong in one component because a sort call three files away reordered the array in place. The bug isn't where the symptom is, which is why these are the bugs you can't reproduce.

Immutable code makes a simple promise: a value, once created, never changes. "Changing" something means creating a new value. Anyone holding the old one is unaffected, forever.

The five rewrites

These are the actual exercises from the course repo. Each is a function you've written a hundred times, in its mutating form — your job is the immutable rewrite.

1. Transforming an arraymap, not index assignment:

// ✗ mutates the caller's array
function doubleArray(arr: number[]): number[] {
  arr.forEach((value, index) => { arr[index] = value * 2 })
  return arr
}

// ✓ returns a new array, caller's data untouched
function doubleArray(arr: number[]): number[] {
  return arr.map((value) => value * 2)
}

2. Accumulatingreduce, not let. A let is a small mutation: a binding that changes over time.

function sumArray(arr: number[]): number {
  return arr.reduce((sum, value) => sum + value, 0)
}

3. Appending — spread, not push:

function pushArray(arr: number[], value: number): number[] {
  return [...arr, value]
}

4. Dropping the head — destructure, don't shift:

function tailArray(arr: number[]): number[] {
  const [, ...tail] = arr
  return tail
}

5. Updating an object — object spread, not assignment:

type Author = { name: string; age: number }

function updateAge(author: Author, newAge: number): Author {
  return { ...author, age: newAge }
}

One subtlety the course flags: when converting an object from one shape to another, prefer building the new object field by field over spreading the old one — a spread silently carries along extra fields that the target type didn't ask for, and you want shape changes to be deliberate.

Don't rely on willpower

Discipline is a lint rule, not a virtue. TypeFirst codebases enforce immutability mechanically — eslint-plugin-functional's immutable-data rule turns arr[i] = x, obj.field = y, push, splice and friends into errors. TypeScript helps too: readonly fields, ReadonlyArray<T>, and you'll notice delete already barely compiles under strict settings.

Why does this matter so much for everything that follows? Because the whole TypeFirst frontend is built on one idea — state is a value, and every change creates a new state via a pure function. That only works if nothing else can reach in and mutate. Immutability isn't a style preference here; it's the foundation the runtime stands on.

Do the exercises. Clone ts-bedrock and work through Learn/Exercise/1-Immutability.ts — the linter will tell you when you've cheated. Next up: Part 2 — Product & sum types.

← All posts