Mert Özen Further With Every Line
Software Design Functional Programming Concepts — #02

Immutability: Why Unchanging Data? record, readonly and TypeScript Readonly in Practice

Mert Özen Nov 15, 2026 13 min 7 views
Immutability: Why Unchanging Data? record, readonly and TypeScript Readonly in Practice

Immutability means a value can't be changed after creation and every change produces a new value; it solves aliasing, lost cache keys and race conditions at the same point. We rewrite Bookstore's cart with record, with and ImmutableList, and show with real output shallow immutability, the cost of copying, the limits of TypeScript readonly and Object.freeze, and why an array pushed in place doesn't update an Angular signal.

Version note: The code in this post was built and run with C# 14 and .NET 10 (SDK 10.0.402); the TypeScript examples with TypeScript 7.0.2, Node.js 26 and Angular 22.2 (@angular/core 22.2.6).

Guess First: Adding a Book to the Cart After the Order Is Placed

In the first post we saw that the checkout code of Bookstore, the online bookstore used as the shared example across this site's series, deletes lines from the caller's cart while computing the total. In another corner of the same codebase, an order is created from the cart like this:

public class LegacyCart
{
    public List<LegacyCartLine> Lines { get; set; } = [];
}

public class LegacyOrder
{
    public List<LegacyCartLine> Lines { get; set; } = [];

    public static LegacyOrder PlaceFrom(LegacyCart cart) => new() { Lines = cart.Lines };
}

The customer places the order and then keeps shopping: adds Middlemarch to the cart and changes the quantity of Dune to 3. How many lines does the order have, and what is the quantity of Dune in the order?

LegacyCart cart = new() { Lines = SampleCarts.Legacy() };
LegacyOrder order = LegacyOrder.PlaceFrom(cart);

// The customer keeps shopping after placing the order
cart.Lines.Add(new LegacyCartLine { Isbn = "9780141439549", Title = "Middlemarch", UnitPrice = 129.90m, Quantity = 1 });
cart.Lines[0].Quantity = 3;

Console.WriteLine($"order lines: {order.Lines.Count}, Dune quantity in order: {order.Lines[0].Quantity}");

Answer: Two Objects Looking at the Same List

$ dotnet run --project src/Bookstore.Fp.Demo -- 02-aliasing
order lines: 4, Dune quantity in order: 3

The cart started with three lines, and so should the order. But PlaceFrom didn't copy the list; it handed over its reference: the cart and the order look at the same List<LegacyCartLine> object. Every line added to the cart went into the order too; and because the line objects are shared as well, the quantity of Dune became 3 in the order. This bug is called aliasing: two names point to the same mutable data, and a change made through one affects the other. It's hard to catch in code review, because the faulty line isn't in PlaceFrom; it shows up in the completely innocent-looking line that changes the cart, perhaps written by another team.

You run into the same problem with cache keys. The price cache is held with a record key; because the record's properties are set, the key can be changed later:

public record PriceQuery
{
    public string Isbn { get; set; } = "";
    public string Currency { get; set; } = "TRY";
}
Dictionary<PriceQuery, decimal> priceCache = [];
PriceQuery query = new() { Isbn = "9780441013593", Currency = "TRY" };
priceCache[query] = 189.50m;

query.Currency = "EUR";

Console.WriteLine($"same object found: {priceCache.ContainsKey(query)}");
Console.WriteLine($"original key found: {priceCache.ContainsKey(new PriceQuery { Isbn = "9780441013593", Currency = "TRY" })}");
Console.WriteLine($"entries: {priceCache.Count}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 02-cache
same object found: False
original key found: False
entries: 1

The entry is still in the dictionary, but no key can find it any more. Dictionary placed the entry in a bucket based on the hash code at the time it was added. When Currency changed, the hash code changed too; searching with the same object looks in a different bucket, and searching with the original values goes to the right bucket, but the key there now says EUR, so equality fails. Using an object whose value can change as a key leaves silently unreachable entries in the cache.

The third cost is in concurrency. If two threads change the same list at the same time, the result depends on the order in which the operations interleave; the Concurrency Theory series post "Race Conditions vs Data Races: Check-Then-Act, Read-Modify-Write and Atomicity" covers this in detail. Unchanging data solves all these problems at the same point: if nobody can change it, sharing it is safe. No lock needed, no copying needed, no lost keys.

Value Semantics: A Value Is What It Is

Immutability means a value can't be changed after it has been created. When a "change" is needed, the old value stays where it is and a new one is produced. In Bookstore, the cart line and the cart become:

public sealed record CartLine(string Isbn, string Title, decimal UnitPrice, int Quantity, bool IsEbook)
{
    public decimal LineTotal => UnitPrice * Quantity;
}

public sealed record Cart(ImmutableList<CartLine> Lines, string? CouponCode = null)
{
    public static Cart Empty { get; } = new(ImmutableList<CartLine>.Empty);

    public Cart Add(CartLine line) => this with { Lines = Lines.Add(line) };

    public Cart WithoutEmptyLines() => this with { Lines = Lines.RemoveAll(line => line.Quantity <= 0) };

    public Cart ChangeQuantity(string isbn, int quantity) =>
        this with { Lines = Lines.ConvertAll(line => line.Isbn == isbn ? line with { Quantity = quantity } : line) };
}

The properties of a positional record are generated as init; they can't be assigned after the constructor. A with expression produces a copy of the existing value, changing only the properties you specify. This is called nondestructive mutation: you write as if you were changing it, but you don't touch the old value. We covered the members the compiler generates for a record and the basics of with in the C# OOP series post "C# record and record struct: Value Equality and the with Expression"; the focus here is applying it to an entire data model.

The Add, RemoveAll and ConvertAll methods of ImmutableList<T> don't change the list; they return a new list. So every method of Cart returns a new Cart:

Cart cart = Cart.Empty
    .Add(new CartLine("9780441013593", "Dune", 189.50m, 1, IsEbook: false))
    .Add(new CartLine("9780141439587", "Emma", 59.90m, 1, IsEbook: true))
    .Add(new CartLine("9780261103344", "The Hobbit", 149.90m, 0, IsEbook: false));

Cart cleaned = cart.WithoutEmptyLines();
Cart withCoupon = cleaned with { CouponCode = "AUTUMN10" };
Cart moreDune = withCoupon.ChangeQuantity("9780441013593", 3);

Console.WriteLine($"cart: {cart.Lines.Count} lines, cleaned: {cleaned.Lines.Count}, coupon on cleaned: {cleaned.CouponCode ?? "none"}");
Console.WriteLine($"Dune in withCoupon: {withCoupon.Lines[0].Quantity}, in moreDune: {moreDune.Lines[0].Quantity}");
Console.WriteLine($"shared Emma line: {ReferenceEquals(withCoupon.Lines[1], moreDune.Lines[1])}");
$ dotnet run --project src/Bookstore.Fp.Demo -- 02-records
cart: 3 lines, cleaned: 2, coupon on cleaned: none
Dune in withCoupon: 1, in moreDune: 3
shared Emma line: True

Every intermediate value stays as it was: when the empty lines were cleaned up, the original cart kept its three lines, adding the coupon didn't affect the cleaned cart, and the quantity of Dune changed only in the last cart. The last line matters too: the unchanged Emma line is shared between the two carts. Because sharing is safe with immutable data, there's no need to copy; the same object can be used in two places at once. The bug in the guess can't be written with this model: even if PlaceFrom hands over the cart's list, the customer can't change that list, only produce a new cart.

For small values such as money, a readonly record struct fits: public readonly record struct Money(decimal Amount, string Currency);. All fields of the struct become readonly, and no one can change a shared copy while it's being copied.

Shallow and Deep Immutability

Writing a record doesn't guarantee immutability on its own. Let's look at a record with a List<T> property:

public sealed record ShallowCart(List<CartLine> Lines, string? CouponCode = null);
ShallowCart first = new([new CartLine("9780441013593", "Dune", 189.50m, 1, IsEbook: false)]);
ShallowCart second = first with { CouponCode = "AUTUMN10" };

second.Lines.Add(new CartLine("9780141439587", "Emma", 59.90m, 1, IsEbook: true));

Console.WriteLine($"first: {first.Lines.Count} lines, second: {second.Lines.Count} lines, same list: {ReferenceEquals(first.Lines, second.Lines)}");

IReadOnlyList<CartLine> readOnly = first.Lines;
((List<CartLine>)readOnly).Clear();
Console.WriteLine($"after cast and Clear: {first.Lines.Count} lines");
$ dotnet run --project src/Bookstore.Fp.Demo -- 02-shallow
first: 2 lines, second: 2 lines, same list: True
after cast and Clear: 0 lines

with produces a shallow copy: it copies the values of the fields. If a field is a reference, the reference is copied, not the list it points to. The Lines property of ShallowCart can't be assigned, but the list it points to can be changed; the aliasing from the guess comes right back. This is called shallow immutability. For deep immutability, every link in the chain must be unchanging: the collection inside the record immutable, and the elements in the collection immutable records.

The second output shows another common mistake: IReadOnlyList<T> is a view, not a guarantee. It limits you to reading methods, but the List<T> behind it is still there and can be changed with a cast. The C# Collections series post "Immutable Collections in C# and Choosing the Right Collection" covers the difference between read-only and immutable in detail.

Immutable Collections: The Cost of Copying

The idea of "a new list for every change" sounds expensive at first. The cost depends on how the collection is built. Let's add one line each to two collections of 10,000 lines and measure the allocated memory:

long before = GC.GetAllocatedBytesForCurrentThread();
ImmutableArray<CartLine> array2 = array.Add(line);
long arrayBytes = GC.GetAllocatedBytesForCurrentThread() - before;

before = GC.GetAllocatedBytesForCurrentThread();
ImmutableList<CartLine> list2 = list.Add(line);
long listBytes = GC.GetAllocatedBytesForCurrentThread() - before;

Console.WriteLine($"ImmutableArray.Add: {arrayBytes} bytes, ImmutableList.Add: {listBytes} bytes ({array2.Length}, {list2.Count})");
$ dotnet run --project src/Bookstore.Fp.Demo -- 02-cost
ImmutableArray.Add: 80032 bytes, ImmutableList.Add: 696 bytes (10001, 10001)

ImmutableArray<T> is built on a single array; adding one element means a new array of 10,001 references (roughly 8 bytes times 10,001). ImmutableList<T>, on the other hand, is a balanced tree: an insert recreates only the nodes on the path from the root to the changed spot, and the rest of the nodes stay shared between the old and the new list. This technique is called structural sharing, and it's the basis of the persistent data structures in functional languages. The rule is simple: ImmutableArray<T> for small data that is read often and changes rarely (fast, allocation-free reads), ImmutableList<T> for large data that changes often.

For lookup tables that are built once and only read for the life of the application, there are FrozenDictionary and FrozenSet, which came with .NET 8. They're expensive to build and fast to read; a good fit for configuration loaded at startup, such as coupon rates:

FrozenDictionary<string, decimal> couponRates = new Dictionary<string, decimal>
{
    ["AUTUMN10"] = 0.10m,
    ["WELCOME5"] = 0.05m
}.ToFrozenDictionary();
Console.WriteLine($"AUTUMN10: {couponRates["AUTUMN10"]}, coupons: {couponRates.Count}");
AUTUMN10: 0.10, coupons: 2

Watch out for one detail: a record's value equality relies on the Equals of its properties. ImmutableList<T> doesn't implement value equality; it compares references. That's why two carts built separately with the same two lines aren't equal:

Cart rebuilt = Cart.Empty
    .Add(new CartLine("9780441013593", "Dune", 189.50m, 1, IsEbook: false))
    .Add(new CartLine("9780141439587", "Emma", 59.90m, 1, IsEbook: true));
Console.WriteLine($"lines equal: {cleaned.Lines.SequenceEqual(rebuilt.Lines)}, carts equal: {cleaned == rebuilt}");
lines equal: True, carts equal: False

If you need to compare carts by value (tests, cache keys), write Equals yourself or use SequenceEqual.

readonly, Readonly and as const in TypeScript

In TypeScript, immutability is the type system's job. readonly arrays, the Readonly<T> mapped type and as const prevent changes at compile time:

import { sampleCart, type CartLine } from "../src/cart.ts";

const cart: readonly Readonly<CartLine>[] = sampleCart();
cart.push(cart[0]);
cart[0].quantity = 3;
const coupon: "AUTUMN10" | "WELCOME5" = "SPRING20";
$ npx tsc -p tsconfig.errors.json
errors/02-readonly-errors.ts(4,6): error TS2339: Property 'push' does not exist on type 'readonly Readonly<CartLine>[]'.
errors/02-readonly-errors.ts(5,9): error TS2540: Cannot assign to 'quantity' because it is a read-only property.
errors/02-readonly-errors.ts(6,7): error TS2322: Type '"SPRING20"' is not assignable to type '"AUTUMN10" | "WELCOME5"'.

To produce a new value instead of changing one, there are the copy methods that came in ES2023: toSorted, toReversed, toSpliced and with. They're the copying counterparts of code that uses sort, reverse, splice and index assignment, and they work on readonly arrays too:

type ReadonlyCartLine = Readonly<CartLine>;

const couponCodes = ["AUTUMN10", "WELCOME5"] as const;
type CouponCode = (typeof couponCodes)[number];

function addLine(lines: readonly ReadonlyCartLine[], line: ReadonlyCartLine): readonly ReadonlyCartLine[] {
  return [...lines, line];
}

const cart: readonly ReadonlyCartLine[] = sampleCart();
const coupon: CouponCode = "AUTUMN10";
const withMiddlemarch = addLine(cart, { isbn: "9780141439549", title: "Middlemarch", unitPrice: 129.9, quantity: 1, isEbook: false });
console.log("lines:", cart.length, "->", withMiddlemarch.length, "coupon:", coupon);

// ES2023 copy methods
const byPrice = cart.toSorted((a, b) => a.unitPrice - b.unitPrice);
const withoutFirst = cart.toSpliced(0, 1);
const moreDune = cart.with(0, { ...cart[0], quantity: 3 });
console.log("first in cart:", cart[0].title, "| first by price:", byPrice[0].title);
console.log("withoutFirst:", withoutFirst.length, "| Dune in cart:", cart[0].quantity, "in moreDune:", moreDune[0].quantity);
$ node src/02-readonly.ts
lines: 3 -> 4 coupon: AUTUMN10
first in cart: Dune | first by price: Emma
withoutFirst: 2 | Dune in cart: 1 in moreDune: 3

as const turns the array into the type readonly ["AUTUMN10", "WELCOME5"], and the expression (typeof couponCodes)[number] derives the union type of coupon codes from it. Remember: all of this happens at compile time. At run time, cart is an ordinary JavaScript array and can be changed through any or a cast. Readonly<T> is shallow too: it doesn't make the properties of nested objects readonly.

Object.freeze and Angular Signals: If the Reference Doesn't Change, Nothing Changes

For run-time protection, JavaScript has Object.freeze, but it's shallow as well. Let's look at it together with the answer to the ★★☆ exercise from the first post:

// Object.freeze is shallow
const frozen = Object.freeze(sampleCart());
try {
  (frozen as CartLine[]).push(frozen[0]);
} catch (error) {
  console.log("push:", (error as Error).constructor.name);
}
frozen[0].quantity = 5;
console.log("nested change kept:", frozen[0].quantity);

// Signals compare references with Object.is
const lines = signal<CartLine[]>(sampleCart());
const count = computed(() => lines().filter((line) => line.quantity > 0).length);
console.log("count:", count());

lines().push({ isbn: "9780141439549", title: "Middlemarch", unitPrice: 129.9, quantity: 1, isEbook: false });
lines.set(lines());
console.log("after push + set(same array):", count(), "| actual:", lines().filter((line) => line.quantity > 0).length);

lines.update((current) => [...current]);
console.log("after new reference:", count());
$ node src/02-freeze-and-signals.ts
push: TypeError
nested change kept: 5
count: 2
after push + set(same array): 2 | actual: 3
after new reference: 3

A push onto the frozen array throws a TypeError in JavaScript modules, where strict mode is the default, but because the line object inside the array isn't frozen, the quantity change silently stays. The details of descriptors and deep freezing are in the JavaScript Advanced series post "JavaScript Object Protection and Immutability: Property Descriptors, seal and freeze".

The signal part is the most common immutability bug in Angular. As the Angular documentation states, signals compare values with Object.is by default, that is, by reference. When you push onto the array in place and call set with the same reference, the signal decides "the value didn't change", and computed returns the old result: 2 lines on screen, 3 in reality. Giving it a new array reference fixes everything. The rule is the same for components using OnPush change detection: the component isn't re-rendered unless the reference of an input changes. In Angular, keeping state immutable isn't a style preference; it's how the framework detects change.

Truly Immutable Values in JavaScript: From Records & Tuples to Composites

TypeScript's readonly doesn't exist at run time, Object.freeze is shallow, and two frozen objects aren't === equal even if their contents are the same. To fill this gap, the Records & Tuples proposal was discussed at TC39 for years: deeply immutable primitives compared by value, with #{ } and #[ ] syntax. The proposal was withdrawn in April 2025 and its repository archived; the teams building browser engines had objected to the cost of a new primitive type with content-based === comparison. The Composites proposal, from the same group, took its place. Composites aren't a new primitive but frozen objects created with Composite({...}), where those with the same contents map to the same object (interning); that lets them be used directly with ===, as Map keys and in a Set. On the day this post was written (November 2026), the proposal's GitHub page says it is at Stage 2; so syntax and behaviour can still change, and it's not something for production.

Common Pitfalls

Thinking a record with set properties is immutable. record gives value equality and with, not immutability. A record with get; set; properties, like PriceQuery above, is the most dangerous combination: compared by value yet changeable, a cache key whose hash code moves.

Forgetting the copying cost of large immutable structures. Every with copies all fields of the record; every ImmutableArray.Add copies the whole array. If you call with or ImmutableArray.Add thousands of times in a loop, either use a structure with structural sharing such as ImmutableList, or change in bulk with a builder (ImmutableArray.CreateBuilder) and make it immutable once at the end. If the change is local and doesn't leak out, using a mutable List<T> inside the method and returning an immutable result is also functional: nobody looking from outside can see the difference.

Making EF Core entities records. EF Core's change tracker follows entities by instance identity and updates them by changing their properties in place. A new instance produced with with isn't tracked; the change either isn't saved or you get an exception because of a second instance with the same key. Value equality also clashes with identity resolution. Keep entities as classes; use immutability in value objects and DTOs. Storing value objects as complex types in EF Core is covered in the DDD series post "Entities and Value Objects: Identity, Equality, and Immutable Models with C# Records".

Relying on the equality of a record that contains an immutable collection. The carts equal: False output above causes surprises in test assertions and cache keys. If you compare a record with an ImmutableList<T> or ImmutableArray<T> property, define equality explicitly.

A Small Experiment

★☆☆ Fix LegacyOrder.PlaceFrom so that it copies the lines (new line objects with cart.Lines.Select(...)). How does the output of the guess change? Does this fix stop the cart from causing aliasing somewhere else later?

★☆☆ Make PriceQuery a positional readonly record struct and read the compiler error for the line query.Currency = "EUR".

★★☆ Write Equals(Cart? other) and GetHashCode() for Cart so that two carts are considered equal based on their lines; verify that the carts equal output becomes True.

★★★ In the Angular example, give signal<CartLine[]>(sampleCart(), { equal: ... }) an equality function that compares line by line. An in-place push + set still doesn't work; why? (Hint: the equality function compares the same array with itself.)

In the next post we move to the functions that work on unchanging data: pure functions and side effects. We'll separate DateTime.Now, the console output and the static field in LegacyCheckout from the calculation, turn the discount rule into a function that always gives the same result for the same input and test it with xUnit.