Skip to content
Commitwise
Menu
Programming

Immutability

Use data that can't change after it's created, update it by making changed copies, tell read-only views from truly immutable collections, and keep immutable objects immutable at their boundaries, in Swift, Kotlin, Java and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A helper sorts the cart “to find the cheapest item”, and the cart screen suddenly shows the items in a different order. Two threads update the same settings object, and one change disappears. A test passes alone and fails when run with the others, because an earlier test changed a shared list. Each bug has the same root: something changed data that someone else was still using. Data that can’t change at all can’t cause any of them.

New to some of these? Open what you need, or skip ahead.

Basics:Mutable or immutable?

A mutable object can be changed after it’s created (list.add(x), user.name = "Ana"). An immutable one can’t: to “change” it, you create a new one with the new value.

Immutable data is easier to reason about: nobody can change it behind your back, and it’s safe to share between parts of the program (and between threads). Kotlin’s val properties and List, Java’s record and List.of, Swift’s let and structs, and TypeScript’s readonly are the usual tools.

Basics:Value types or reference types?

Assigning a value type (Swift structs, numbers everywhere) makes a copy; assigning a reference type (classes, Kotlin and Java objects, TypeScript objects and arrays) makes a second name for the same object. With reference types, changing the object through one name is visible through every other name. The Value types vs reference types tutorial explains it in detail, including why let and val don’t freeze an object.

Part Not started

Why change is dangerous

Imagine a shared shopping list on the fridge. You cross out “milk” because you bought it; your flatmate, reading the list five minutes earlier, already left to buy milk too. The list changed while someone else was relying on it.

Now imagine every change produces a new list instead, and the old one stays exactly as it was. Whoever is holding the old one still has a consistent picture, and you hand the new one around when you’re done. That’s immutability: once created, data never changes; “changing” it means creating a new version.

Shared mutable state is the common factor in aliasing bugs (a function mutates its argument), temporal coupling (results depend on call order), and data races. Immutability removes the “mutable” half: values can be shared freely, cached, used as map keys, compared by value, and published across threads without locks. The costs are copying and, in JVM/JS, the gap between read-only types and truly immutable data, which is where most real bugs hide.

Spot the bugFinding the cheapest item reorders the cart

Tap the line with the bug.

final class Cart {
    private(set) var items: [Item] = []

    func cheapestItem() -> Item? {
        items.sort { $0.priceCents < $1.priceCents }
        return items.first
    }
}
class Cart {
    val items = mutableListOf<Item>()

    fun cheapestItem(): Item? {
        items.sortBy { it.priceCents }
        return items.firstOrNull()
    }
}
class Cart {
    private final List<Item> items = new ArrayList<>();

    Item cheapestItem() {
        items.sort(Comparator.comparingInt(Item::priceCents));
        return items.isEmpty() ? null : items.get(0);
    }
}
class Cart {
  readonly items: Item[] = [];

  cheapestItem(): Item | undefined {
    this.items.sort((a, b) => a.priceCents - b.priceCents);
    return this.items[0];
  }
}

The rule that prevents this kind of bug: functions that answer a question don’t change anything, and functions that change something say so in their name (sort() vs sorted(), reverse() vs reversed()). Immutable data makes the rule automatic: if the list can’t be changed, a careless sort doesn’t compile.

The tools in each language

SwiftKotlinJavaTypeScript
A property that can’t be reassignedletvalfinalreadonly
A data type that’s easy to keep immutablestruct with let propertiesdata class with valsrecordtype with readonly fields
A list that can’t be changedlet array (a value)List (read-only view, see below)List.of, List.copyOfReadonlyArray, readonly T[]
A changed copyvar copy = x; copy.name = …x.copy(name = …)a new record (or a withName method){ ...x, name: … }

Swift has the easiest time: a struct with let properties is a value, so every assignment is a copy and nothing is ever shared. The other languages share objects by reference, so immutability is a promise each type has to keep.

Immutability and threads

An immutable object, safely published, needs no synchronization: every thread sees the same, complete state. On the JVM, final fields carry a guarantee from the Java Memory Model that a constructed object’s final fields are visible to other threads without locks (as long as this didn’t escape the constructor). Swift 6 strict concurrency encodes the same idea in types: a struct of Sendable values, or a final class with only let Sendable properties, is Sendable and may cross isolation boundaries. In JavaScript, workers share nothing by default, and immutable messages make structured cloning predictable. In practice: make the things you pass between threads (events, state snapshots, configuration) immutable values, and keep mutation inside one owner (an actor, a single thread, a reducer).

  • Shared mutable state causes aliasing bugs, order dependence and data races. Immutable values can be shared, cached, used as keys and passed between threads freely.
  • Query methods must not mutate: min/minByOrNull/stream().min/reduce or sorted copies (sorted, sortedBy, toSorted), never in-place sort on shared state.
  • Thread safety: JVM final fields get safe-publication guarantees; Swift Sendable structs and let-only final classes cross isolation boundaries.

Check this part

  1. A method that finds the cheapest item calls sort on the cart's own list. What's the problem?

  2. What does "immutable" mean?

  3. Why can immutable objects be shared between threads without locks?

Bugs appear when one part of the program changes data another part is still using. Functions that return new data instead of changing their input avoid it, and so does data that can't be changed at all.

Part Not started

Changing by copying

struct User {
    let name: String
    let city: String
}

let ana = User(name: "Ana", city: "Novi Sad")
let moved = User(name: ana.name, city: "Belgrade")   // or make city a var and copy
print(ana.city, moved.city)                          // Novi Sad Belgrade
data class User(val name: String, val city: String)

val ana = User("Ana", "Novi Sad")
val moved = ana.copy(city = "Belgrade")              // everything else stays the same
println("${ana.city} ${moved.city}")                 // Novi Sad Belgrade
record User(String name, String city) {
    User withCity(String city) { return new User(name, city); }
}

User ana = new User("Ana", "Novi Sad");
User moved = ana.withCity("Belgrade");
System.out.println(ana.city() + " " + moved.city()); // Novi Sad Belgrade
type User = Readonly<{ name: string; city: string }>;

const ana: User = { name: "Ana", city: "Novi Sad" };
const moved: User = { ...ana, city: "Belgrade" };    // everything else stays the same
console.log(ana.city, moved.city);                   // Novi Sad Belgrade

Instead of changing Ana’s city, you create a new User that has everything Ana has, except the city. The original is untouched, so any screen still showing the old one stays consistent, and “undo” is as simple as keeping the old value.

In Swift, a common style is to make properties var inside a struct and copy the whole struct: var moved = ana; moved.city = "Belgrade". Because structs are values, ana doesn’t change.

Copy-with-changes is the idiom everywhere: Swift structs (value semantics plus copy-on-write for collections), Kotlin copy (shallow, generated for primary-constructor properties), Java records (manual withX methods; derived record creation is a proposed language feature), TypeScript spread (shallow). Nested updates are where it gets verbose and where shallow copies leak shared mutable inner objects, so nested data must be immutable all the way down.

Read-only isn’t immutable

Some languages have types that only promise you won’t change the data, without promising that nobody else will. They’re read-only views:

Predict the output

A list is shared through a read-only type, then the original is changed. What does the read-only side see?

var a = [1, 2]
let b = a
a.append(3)
print(b)
val list = mutableListOf(1, 2)
val readOnly: List<Int> = list
list.add(3)
println(readOnly)
List<Integer> list = new ArrayList<>(List.of(1, 2));
List<Integer> view = Collections.unmodifiableList(list);
list.add(3);
System.out.println(view);
const arr = [1, 2];
const readOnly: readonly number[] = arr;
arr.push(3);
console.log(readOnly);
Exerciseeasy

Update a nested address

A Profile has a name and an address with street and city, all immutable. Write moveTo(profile, city) that returns a new profile with the new city, leaving the original untouched.

›

You need a new Address (with the new city) and a new Profile (with the new address). Copy from the inside out.

SolutionTry it yourself first
struct Address { var street: String; var city: String }
struct Profile { var name: String; var address: Address }

func moveTo(_ profile: Profile, city: String) -> Profile {
    var updated = profile                 // a copy: Profile is a value
    updated.address.city = city           // changes only the copy
    return updated
}
data class Address(val street: String, val city: String)
data class Profile(val name: String, val address: Address)

fun moveTo(profile: Profile, city: String): Profile =
    profile.copy(address = profile.address.copy(city = city))
record Address(String street, String city) {}
record Profile(String name, Address address) {}

Profile moveTo(Profile profile, String city) {
    return new Profile(profile.name(), new Address(profile.address().street(), city));
}
type Address = Readonly<{ street: string; city: string }>;
type Profile = Readonly<{ name: string; address: Address }>;

function moveTo(profile: Profile, city: string): Profile {
  return { ...profile, address: { ...profile.address, city } };
}

Each level that changes is copied; everything else is reused. The new profile and the old one share the name string (and in bigger structures, every unchanged branch), which is safe precisely because nothing can change. Swift gets the shortest code because structs copy themselves, so var properties inside a value are still safe.

Kinds of “can’t change”

GuaranteeSwiftKotlinJavaTypeScript
Variable can’t be reboundletvalfinalconst, readonly
This reference can’t mutate it (read-only view)(n/a: values)List, Map, Set interfacesCollections.unmodifiable*ReadonlyArray, Readonly<T> (compile-time only)
Nobody can mutate it (immutable)value types, letkotlinx.collections.immutable (persistentListOf)List.of, List.copyOf, Map.of, records of immutable fieldsObject.freeze (shallow, runtime)
Cheap updates with sharingcollections via copy-on-writepersistent collections(libraries: Vavr, Guava’s builders)Immer, persistent libs

Most production confusion is the second row masquerading as the third: a Kotlin List parameter can change between two reads if the caller holds the MutableList, and an unmodifiableList view reflects later writes. When the guarantee matters (across threads, in caches, as map keys), take a snapshot at the boundary: toList(), List.copyOf, a spread, or a persistent collection.

  • Copy-with-changes: Swift struct copy (or var fields on a value), Kotlin copy() (shallow), Java record withX, TypeScript spread (shallow). Nested updates copy each changed level.
  • Read-only ≠ immutable: Kotlin List interface, Java unmodifiableList views and TypeScript readonly all reflect writes through another reference. Snapshot at boundaries (toList(), List.copyOf, spread) or use persistent collections.
  • Object.freeze is runtime and shallow; Readonly<T> is compile-time only.
let snapshot = items                         // a value: independent from here on
var next = state; next.cart.items.append(item)
val snapshot = items.toList()                // or persistentListOf(...) from kotlinx
val next = state.copy(cart = state.cart.copy(items = state.cart.items + item))
List<Item> snapshot = List.copyOf(items);    // immutable copy (rejects null elements)
State next = state.withCart(state.cart().withItem(item));
const snapshot = Object.freeze([...items]);
const next = { ...state, cart: { ...state.cart, items: [...state.cart.items, item] } };

Check this part

  1. How do you get a version of an immutable struct with one field changed?How do you get a version of a data class with one field changed?How do you get a version of a record with one field changed?How do you get a version of a readonly object with one field changed?

  2. A nested address must change. What gets copied?

  3. Which other language's "read-only list" can still change underneath you?A function receives List<Int>. Can it change between two reads?Does Collections.unmodifiableList(list) reflect later changes to list?Does readonly number[] stop another reference from pushing to the array?

To change immutable data, create a changed copy: Swift struct copies, Kotlin copy(), new Java records, TypeScript spread. A read-only type (Kotlin List, Java unmodifiable views, TypeScript readonly) only stops you from changing it; someone else may still change the data underneath.

Part Not started

Keeping immutable things immutable

A sealed envelope is only safe if what’s inside can’t change. If you seal a note that says “see the shared document”, anyone with access to that document can still change what the envelope effectively says.

An immutable type that holds a reference to a mutable object (a list, a class instance) has the same weakness: the outer object can’t change, but the thing inside can.

Immutability is transitive only if you make it so. record, data class with vals, Readonly<T> and Swift structs freeze their own fields; a field that refers to a mutable collection or class is a hole. Close it at construction (defensive copy into an immutable collection) and at exposure (return snapshots or read-only views of immutable data).

struct Order {
    let id: String
    let items: [Item]                // Array is a value: the struct's copy can't be changed elsewhere
}                                    // (as long as Item is a value type too)
class Order(val id: String, items: List<Item>) {
    val items: List<Item> = items.toList()   // a private snapshot; callers' lists can't change it
}
record Order(String id, List<Item> items) {
    Order {
        items = List.copyOf(items);          // defensive copy into an immutable list
    }
}
class Order {
  readonly items: readonly Item[];
  constructor(readonly id: string, items: readonly Item[]) {
    this.items = Object.freeze([...items]);  // a frozen snapshot
  }
}

The order copies the list it’s given. Whoever created the order can keep adding to their own list; the order’s items stay exactly as they were when it was created. In Swift, arrays already behave this way.

Local mutation is fine

Immutability is about shared data. Inside one function, building a result with a mutable list and returning it (or returning an immutable copy) is perfectly fine and often clearer and faster:

func evenSquares(_ numbers: [Int]) -> [Int] {
    var result: [Int] = []           // local and private: nobody else can see it
    for n in numbers where n % 2 == 0 { result.append(n * n) }
    return result                    // returned as a value
}
fun evenSquares(numbers: List<Int>): List<Int> = buildList {   // mutable inside, List outside
    for (n in numbers) if (n % 2 == 0) add(n * n)
}
List<Integer> evenSquares(List<Integer> numbers) {
    List<Integer> result = new ArrayList<>();
    for (int n : numbers) if (n % 2 == 0) result.add(n * n);
    return List.copyOf(result);      // share an immutable copy
}
function evenSquares(numbers: readonly number[]): readonly number[] {
  const result: number[] = [];
  for (const n of numbers) if (n % 2 === 0) result.push(n * n);
  return result;                     // typed as readonly for callers
}

Production puzzles

Spot the bugAn immutable config that changed

Tap the line with the bug.

final class Limits { var maxUploads = 5 }

struct AppConfig {
    let name: String
    let limits: Limits
}

let config = AppConfig(name: "prod", limits: Limits())
featureFlags.apply(to: config.limits)     // sets maxUploads = 50 for a test cohort
data class AppConfig(
    val name: String,
    val allowedHosts: MutableList<String>,
)

val config = AppConfig("prod", mutableListOf("api.example.com"))
debugMenu.addHost(config.allowedHosts)    // adds "localhost" in a debug screen
record AppConfig(String name, List<String> allowedHosts) {}

List<String> hosts = new ArrayList<>(List.of("api.example.com"));
AppConfig config = new AppConfig("prod", hosts);
hosts.add("localhost");                   // the caller keeps using its list
const config = Object.freeze({
  name: "prod",
  allowedHosts: ["api.example.com"],
});

config.allowedHosts.push("localhost");

Predict the output

Two snapshots of app state are compared by reference to decide whether to re-render. After an update, how many of these are true: newState !== oldState, newState.cart !== oldState.cart, newState.user !== oldState.user?

var newState = oldState
newState.cart.items.append(item)
// Swift structs compare by value; arrays share storage until mutated (copy-on-write).
val newState = oldState.copy(cart = oldState.cart.copy(items = oldState.cart.items + item))
State newState = oldState.withCart(oldState.cart().withItem(item));
const newState = { ...oldState, cart: { ...oldState.cart, items: [...oldState.cart.items, item] } };
Exercisemedium

An immutable state store

A screen’s state is cart (items, coupon), user and isLoading, and it’s changed from several places: network callbacks, buttons, a timer. Bugs include lost updates and a UI that sometimes shows a half-updated cart. Design a small store: an immutable state type, a single function that produces the next state from the current one and an action, and one place that holds the current state.

›

The pattern is a reducer: reduce(state, action) -> state. Actions are a sealed type or enum; the reducer is pure; only the store assigns the new state, on one thread or actor.

SolutionTry it yourself first
struct CartState: Equatable { var items: [Item] = []; var coupon: String? }
struct AppState: Equatable { var cart = CartState(); var user: User?; var isLoading = false }

enum Action { case added(Item), couponApplied(String), loaded(User), loading(Bool) }

func reduce(_ state: AppState, _ action: Action) -> AppState {
    var next = state                                  // a copy; the old state is untouched
    switch action {
    case .added(let item):        next.cart.items.append(item)
    case .couponApplied(let c):   next.cart.coupon = c
    case .loaded(let user):       next.user = user; next.isLoading = false
    case .loading(let flag):      next.isLoading = flag
    }
    return next
}

@MainActor @Observable
final class Store {
    private(set) var state = AppState()
    func send(_ action: Action) { state = reduce(state, action) }   // one writer, one place
}
data class CartState(val items: List<Item> = emptyList(), val coupon: String? = null)
data class AppState(val cart: CartState = CartState(), val user: User? = null, val isLoading: Boolean = false)

sealed interface Action {
    data class Added(val item: Item) : Action
    data class CouponApplied(val code: String) : Action
    data class Loaded(val user: User) : Action
    data class Loading(val flag: Boolean) : Action
}

fun reduce(state: AppState, action: Action): AppState = when (action) {
    is Action.Added -> state.copy(cart = state.cart.copy(items = state.cart.items + action.item))
    is Action.CouponApplied -> state.copy(cart = state.cart.copy(coupon = action.code))
    is Action.Loaded -> state.copy(user = action.user, isLoading = false)
    is Action.Loading -> state.copy(isLoading = action.flag)
}

class Store {
    private val _state = MutableStateFlow(AppState())
    val state: StateFlow<AppState> = _state
    fun send(action: Action) = _state.update { reduce(it, action) }   // atomic compare-and-set
}
record CartState(List<Item> items, String coupon) {
    CartState { items = List.copyOf(items); }
    CartState withItem(Item item) {
        var next = new ArrayList<>(items); next.add(item);
        return new CartState(next, coupon);
    }
}
record AppState(CartState cart, User user, boolean isLoading) {}

sealed interface Action {
    record Added(Item item) implements Action {}
    record CouponApplied(String code) implements Action {}
    record Loaded(User user) implements Action {}
    record Loading(boolean flag) implements Action {}
}

static AppState reduce(AppState s, Action action) {
    return switch (action) {
        case Action.Added a -> new AppState(s.cart().withItem(a.item()), s.user(), s.isLoading());
        case Action.CouponApplied c -> new AppState(new CartState(s.cart().items(), c.code()), s.user(), s.isLoading());
        case Action.Loaded l -> new AppState(s.cart(), l.user(), false);
        case Action.Loading l -> new AppState(s.cart(), s.user(), l.flag());
    };
}

final class Store {
    private final AtomicReference<AppState> state = new AtomicReference<>(new AppState(new CartState(List.of(), null), null, false));
    AppState state() { return state.get(); }
    void send(Action action) { state.updateAndGet(s -> reduce(s, action)); }   // atomic, retried on conflict
}
type CartState = Readonly<{ items: readonly Item[]; coupon?: string }>;
type AppState = Readonly<{ cart: CartState; user?: User; isLoading: boolean }>;

type Action =
  | { type: "added"; item: Item }
  | { type: "couponApplied"; code: string }
  | { type: "loaded"; user: User }
  | { type: "loading"; flag: boolean };

function reduce(state: AppState, action: Action): AppState {
  switch (action.type) {
    case "added": return { ...state, cart: { ...state.cart, items: [...state.cart.items, action.item] } };
    case "couponApplied": return { ...state, cart: { ...state.cart, coupon: action.code } };
    case "loaded": return { ...state, user: action.user, isLoading: false };
    case "loading": return { ...state, isLoading: action.flag };
  }
}

class Store {
  #state: AppState = { cart: { items: [] }, isLoading: false };
  get state(): AppState { return this.#state; }
  send(action: Action): void { this.#state = reduce(this.#state, action); }   // JS is single-threaded per realm
}
  • No half-updated state: observers only ever see complete snapshots, because the reducer builds the next state before it’s published.
  • No lost updates: one writer. Swift isolates the store to the main actor; Kotlin’s MutableStateFlow.update and Java’s AtomicReference.updateAndGet retry the pure reducer on contention (which is why it must be pure); JavaScript runs one task at a time.
  • Testable: reduce is a pure function; tests are input state + action → expected state, no mocks.
  • Cost: each update copies only the changed path (structural sharing). For very large collections, use persistent collections instead of copying arrays.
  • Immutability is shallow by default: a record/data class/Readonly/frozen object holding a mutable list, or a Swift struct holding a class, can still change. Defensive-copy mutable inputs (List.copyOf in compact constructors, toList(), frozen spreads), make nested types immutable too.
  • Local, private mutation is fine (builders, buildList); share immutable results.
  • State stores: immutable state + pure reducer + single writer (@MainActor, MutableStateFlow.update, AtomicReference.updateAndGet); structural sharing makes reference checks cheap for UI diffing.

Check this part

  1. An immutable Order stores the list it was given, without copying. What can go wrong?

  2. Is using a mutable list inside a function to build a result OK?

  3. A struct with let properties holds a class instance. Is it immutable?data class Config(val hosts: MutableList<String>). Is a Config immutable?record Config(List<String> hosts) built from the caller's ArrayList. Is it immutable?Is Object.freeze({ hosts: [] }) deeply immutable?

  4. After an immutable update to the cart, is state.user the same object as before?

An immutable type is only as immutable as its fields: a record holding a mutable list, or a Swift struct holding a class, can still change. Copy mutable inputs when constructing and expose read-only snapshots. Mutation is fine when it's local and private.

Key takeaways

  • Bugs happen when code changes data that someone else is still using; functions that answer questions shouldn’t change anything.
  • Immutable data never changes; to “change” it, create a changed copy (copy, spread, a new record, a struct copy).
  • Read-only types (Kotlin List, Java unmodifiable views, TypeScript readonly) don’t stop others from changing the data.
  • Copy mutable inputs into immutable objects, and feel free to use mutation locally while building a result.
  • Immutable values can be shared, cached and passed across threads without locks.
  • Distinguish rebinding, read-only views and immutability; snapshot at boundaries when the guarantee matters.
  • Immutability is shallow unless you make it transitive; defensive copies in constructors close the holes.
  • Immutable state + pure reducer + single writer, with structural sharing, eliminates lost and partial updates.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.