Type Driven Development: Immutability

Written by

in

Part 23 – Immutability

This is a series of posts I’m writing about using types as another tool in software development, Continuous Delivery, & keeping LLM’s honest. They’re also a design & refactoring tool, a communication tool, and reduce how many tests you have to write.

Parts
  1. Part 1 – Branded Types
  2. Part 2 – Product Types
  3. Part 3 – Union & Discriminated Unions
  4. Part 4 – Non-Empty Collections
  5. Part 5 – Indexed Types
  6. Part 6 – unknown vs any
  7. Part 7 – Result
  8. Part 8 – Schema
  9. Part 9 – Total Function
  10. Part 10 – Errors as Values
  11. Part 11 – Property Tests
  12. Part 12 – Type Proofs
  13. Part 13 – Exhaustiveness Checking
  14. Part 14 – Parse, Don’t Validate
  15. Part 15 – Anti-Corruption Layer
  16. Part 16 – Opaque Types
  17. Part 17 – Maybe
  18. Part 18 – Smart Constructors
  19. Part 19 – Pipeline
  20. Part 20 – Railway Oriented Programming
  21. Part 21 – Typestate
  22. Part 22 – Capabilities
  23. Part 23 – Immutability
  24. Part 24 – Making Impossible States Impossible
  25. Part 25 – Type Driven Development: How to do it
  26. Part 26 – Final Thoughts

Mutation is a side-effect, has a long list of bugs it creates, and yet remains the most controversial choice despite using immutable data being easier to do nowadays without the speed tradeoffs. At a distance bug:

user = { permissions: ['read'] }
cachedUser = user

// some code far away from user
// changes it, possibility negatively affecting
// code is knows nothing about
user.permissions.push('admin')Code language: JavaScript (javascript)

Using immutability, everyone has their own copy to prevent this.

In pipelines, you have to read the code to know you call applyDiscount first before you call chargeCustomer. The type signature tells you none of that.

Even in sync code, you can corrupt data if you write imperative code and stop running it halfway:

function submitOrder(order) {
  order.status = "submitted"
  payment = charge(order)
  if (!payment.ok) {
    // payment won't have an id
    return
  }
  
  order.paymentId = payment.id
}Code language: JavaScript (javascript)

Immutability, it only creates new thing when the return is successful, negating partial data updates.

Mutation makes debugging hard:

account = { balance: 100 }
account.balance -= 30

Can’t see previous state, compare it to the current, audit, nor rollback. Immutability has all of that built-in w/ability to build atop.

When you call a function it can change your parameter b/c Array.sort is mutable:

const original = [charlie, alice, bob]
const sorted = sortUsers(original)

// this is also sorted
console.log(original)Code language: JavaScript (javascript)

Pure/Total functions don’t “change the world” before/after they run, preventing this from happening.

Many other bugs like some unit tests breaking other tests randomly, async code where 2 promises can mutate a single value causing problems, breaking the domain business rules by mutating business invariants encoded in types, the list goes on. You have to read all the code to “know the order in which something was mutated”.

Using immutable data, all of your type signatures don’t lie as to what’s happening (with the exclusion of Haskell’s StateMonad or Rust’s borrow checker/exclusion). It does NOT ensure purity/non-determinism, but is part of it. You don’t even need to use readonly, or ReadOnly<T> types; just follow the convention “We don’t mutate things, we just return copies via object spread syntax” (or the Python/Luau/your language equivalent). You don’t even need structuredClone if everyone just follows the convention.

This is the most controversial, even with those not concerned at all by performance, and can be sometimes the non-starter to any good type initiative in engineering cultures. Make no mistake, your life is better & easier without mutation except for various edge cases.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *