AlgoMaster Logo

Immutable Types

Medium Priority17 min readUpdated June 6, 2026

An immutable type is one whose data can't change after the object is built. Construct it once, pass it around, and every part of the program sees the same values forever. This lesson covers how to design types like that in C#, the common traps, and when immutability is the wrong call.

Why Immutability

Mutable objects make code harder to reason about. If a Cart object can change at any point, and ten different pieces of code hold a reference to it, any of them can mutate it from underneath the others. That's the kind of bug where "the total was right two lines ago, and now it's wrong, and nothing visible changed."

Immutability removes that whole category of bug. Once an object is built, its fields are locked. It can be passed to a logging service, stored in a cache, or handed to three different methods, and it still has the same values when read next.

Concrete benefits:

  • Thread-safe by construction. With no possible changes, two threads reading the same object at the same time can't race. No locks, no Interlocked, no memory barriers needed.
  • Predictable behavior for callers. A method that takes a Money value can use it freely, knowing the caller can't reach back in and change Amount after the fact. There's no action at a distance where one part of the code mutates an object another part is still using.
  • Safe to share. The same instance can be returned from a method ten times without worry. No defensive copying on the way out.
  • Stable hash codes. A mutable object used as a dictionary key is a bug source. If the key's data changes after it's inserted, the dictionary can't find it again because the hash code moved. Immutable types make this impossible.

A sketch of the difference. A shared reference to a mutable object versus a shared reference to an immutable one:

In the top half, any of A, B, or C can change the cart, and the other two have to deal with it. In the bottom half, the address never changes, so all three see identical data regardless of order. The shared reference is safe.

The trade-off is allocation. "Modifying" an immutable object means building a new one. The cost is covered later in the lesson.

Building an Immutable Class by Hand

The hand-rolled recipe for an immutable class has three rules:

  1. Every field is readonly.
  2. Every property is get-only (no set accessor).
  3. No method on the type changes any field after the constructor finishes.

Here's a Money value done that way:

The constructor validates and then assigns. Both fields are readonly, so even code inside the same class can't reassign them after the constructor finishes. The properties expose them without setters, so external code reads but can't write. The class has no method that mutates anything, by design.

That's a complete, immutable type. Once new Money(29.99m, "USD") runs, that instance is frozen for life.

The same pattern with auto-properties looks like this, using the init accessor introduced in C# 9:

The init keyword makes the property settable during object construction (via constructor or object initializer) and never again. The result is the same kind of immutable type with less ceremony. Use explicit backing fields when validation logic belongs in the constructor, and init-only properties when the type is a data carrier.

Building any C# object allocates on the heap (unless it's a struct). Immutable types don't change that; they trade in-place mutation for allocating a new instance with the updated value. For small objects this is cheap. For huge ones, see the "When Not to Use Immutability" section below.

The readonly Field Modifier (and What It Doesn't Prevent)

readonly means "the field can only be assigned in the declaration or in a constructor." That's a precise definition, and the precision matters when the field is a reference type.

readonly prevents rebinding the field. It does not prevent mutating the object the field points to.

That distinction is a common source of "I thought this was immutable" bugs. The canonical example:

The field ItemPrices still points at the same List<int> as it did at construction. The compiler enforces that, no rebinding allowed. But the list itself isn't readonly. The list has Add, Insert, and an indexer setter, all of which mutate the list's internal state. Calling those is fine from the compiler's perspective.

The same problem exists for any mutable reference type: arrays, Dictionary<K, V>, custom mutable classes, even custom types with setters. The readonly keyword doesn't reach inside.

Three common patterns where readonly "leaks" and what to do about them:

LeakWhat happensFix
public readonly List<T>Callers can call .Add(), .Remove(), etc. on the listExpose as IReadOnlyList<T> (a property returning the field), or store ImmutableList<T>
public readonly int[]Callers can write to elements: arr[0] = 999Expose as IReadOnlyList<int>, or return a copy from a method, or use ImmutableArray<int>
public readonly Address Address (where Address is a mutable class)Callers can mutate address.Street = "..."Make Address itself immutable, or return a copy

The general fix is to either make the contained type immutable too, or expose a read-only view of it. The fix applied to the cart:

The list is now private, and the public property exposes it through the IReadOnlyList<int> interface. That interface has no Add, no Remove, no indexer setter. Callers can read but they can't write. A determined caller could cast it back to List<int> to mutate it, so this is a soft barrier rather than a wall, but it stops every reasonable use case.

Defensive Copying of Mutable Inputs

Closely related to the readonly leak: when a constructor accepts a mutable collection or object, the caller still holds a reference to it. If the caller mutates that reference later, the supposedly immutable object isn't immutable anymore.

This bug:

The order looks immutable from the outside (no setter, no mutating methods, list exposed as IReadOnlyList<T>), but the caller's reference is the same object the order is storing internally. Mutating one mutates the other.

The fix is to copy on the way in. The constructor takes the data, but it stores its own private copy:

Now the order owns its own list. The caller can mutate the original items freely, and the order's internal list is untouched.

If the type exposes a mutable collection as a return value too, the same logic applies on the way out. Either return an IReadOnlyList<T> (no copy needed, just a typed view) or return a fresh copy when the caller should have a separate, mutable list.

Defensive copying is more than a collections concern. It applies any time a constructor accepts a mutable reference type:

Input typeMutable?Defensive copy needed?
int, decimal, DateTime, GuidNo (value types)No
stringNo (strings are immutable in .NET)No
int[], string[], List<T>, Dictionary<K,V>YesYes, copy on input
IReadOnlyList<T> parameterCaller's underlying object might still be mutableUsually yes, copy to List<T> or ImmutableArray<T>
A custom class with settersYesYes, or document that the input must not be mutated
A record or other immutable typeNoNo

The rule of thumb: if the input type has any way to change after construction, take a defensive copy. When an immutable type is available (a record, an ImmutableArray<T>, a primitive), use that instead and skip the copy.

Copying a list on construction is O(n). For small collections this is invisible. For collections in the millions of elements being constructed in tight loops, it adds up. In those cases, take an ImmutableArray<T> parameter so the caller and the type can share the same underlying storage without copying.

Records as a Shortcut for Immutability

Writing immutable types by hand is a lot of ceremony. Constructor, fields, properties, optionally Equals and GetHashCode overrides for value equality, plus a sensible ToString. Records collapse all of that to one line. The "immutability shortcut" lens:

The minimum a record provides automatically:

  • All positional properties are init-only. Immutable by default.
  • Value-based Equals and GetHashCode. Two records with the same data compare equal.
  • A readable ToString.
  • A constructor that takes every property in declaration order.

That maps almost perfectly onto the hand-rolled immutable type:

For most "value-shaped" types like Address, Money, Coupon, or CartLine, a record is the right starting point. The simplest possible Address:

One line of declaration, fully immutable, value-equality, useful ToString. The cost is zero versus the hand-rolled version, and behaviors (Equals, GetHashCode) come included that would often be skipped in a manual version.

The deeper record machinery, with expressions for non-destructive updates, positional deconstruction, record structs, primary constructors, lives in the Modern C# Features section. For now, treat records as the default shape for immutable data types, and use the hand-rolled form only when explicit control over storage or validation is needed.

Value Objects: Money and Address

A value object is a type whose identity is its data. Two Money values with the same amount and currency are the same money. No other property distinguishes them. Compare that to an Order, which has an OrderId separate from its contents: two orders with the same items are still two different orders.

Value objects are the canonical use case for immutable types in domain code. They're small, they compare by value, they don't change, and they're safe to share. Money as a record:

Add returns a new Money rather than mutating either input. That's the pattern for value objects: operations produce new values, the originals are untouched. The currency check throws on mismatch to prevent a "10 USD + 5 EUR = 15 ???" bug. price == samePrice returns True because records do value equality automatically.

Address works the same way:

The "two value objects are equal if all their fields are equal" rule is automatic for records. Writing Address as a regular class would require overriding Equals, GetHashCode, and == manually to get the same behavior, which is exactly the boilerplate that records were designed to remove.

The general design rule for value objects:

  • Immutable. Properties are init-only or get-only.
  • Value equality. Two instances with identical data are equal.
  • Self-validating. The constructor throws if the inputs are nonsensical (negative money, missing currency, empty zip).
  • Small. A value object that grows to twenty fields is usually two value objects in disguise.

Non-Destructive Update: The Wither Pattern

When an immutable type needs an "updated" version of itself, the pattern is to return a new instance with one or more fields changed. By convention these methods are named WithSomething, which is where the name "wither" comes from.

The wither pattern on a hand-rolled immutable Product:

Each WithXxx method builds a new Product, copying every field over and overwriting the one being changed. The original is untouched, so anyone holding a reference to it still sees the same data. The wither methods chain naturally: original.WithDiscount(0.20m).WithStock(50) is a readable way to produce a new product with two changes.

This pattern shows up in two places. The first is hand-written immutable types that predate records, where the witherers are written manually. The second is in modern code that needs a named, validated update operation (like WithDiscount enforcing that the percentage is between 0 and 1) rather than a generic field setter.

For records, the language has built-in support that does the same thing without the boilerplate. The with keyword reads like this:

That with expression generates the equivalent of a wither call automatically. For records, prefer with expressions. For hand-rolled immutable classes or cases that need validation in the update, prefer named WithXxx methods.

Every wither call allocates a new object. Chaining ten witherers in a hot loop produces ten allocations per iteration. The fix is usually to compute all the changes first and pass them into a single constructor call, or to question whether this part of the code needs immutability.

When Not to Use Immutability

Immutability has a cost, and it isn't always the best fit. Mutable types are the better choice in a few situations.

Hot paths with frequent updates. If a method updates a counter a million times per second, allocating a new object on every update is wasteful. The garbage collector will handle it eventually, but the allocations buy nothing. A mutable Counter with an int field and an Increment method fits here.

Large object graphs where every update copies the world. Consider a Catalog with ten thousand products. Making Catalog immutable and changing one product's price with the wither approach copies all ten thousand entries. Persistent data structures (the immutable collections in System.Collections.Immutable) avoid most of this by sharing internal structure, but they still cost more than in-place updates. For very large graphs, this can matter.

EF Core and ORM entities. Object-relational mappers expect to track entities and observe mutations to flush them to the database. EF Core snapshots an entity's state on load, then diffs it on save. Immutable entities can't be updated in place, and the framework can't see the changes. There are workarounds (replace the entity, work with DTOs), but the friction is real. For entities tracked by an ORM, mutable types are usually less painful.

Performance-critical buffers and builders. Building a string by concatenating a thousand pieces should use StringBuilder, not a thousand new strings. Building a JSON document should use a mutable writer, not a thousand immutable nodes. These types are "build it up, then read from it" by design.

Tracking real-world state that changes. A ShoppingCart represents something the user is editing in real time. It's the same cart across the session, items get added, the total updates. Modeling it as immutable means rebuilding the entire cart on every keystroke, which is more pain than gain. The cart is the mutable thing; the products inside it are immutable values.

The rule of thumb: immutable for values, mutable for the things that hold values. A Product is a value (immutable). A Cart is the thing holding products (mutable). A Money is a value (immutable). An Order that accumulates items is the thing (mutable, at least during checkout).

When every method in a path takes a mutable input, calls WithXxx three times, and returns a new instance, immutability is being paid for without much benefit. Either commit to immutability everywhere along that path or use a small mutable object scoped to that flow.

A Note on Immutable Collections

The System.Collections.Immutable namespace ships with the .NET BCL and provides truly immutable counterparts to the standard collection types: ImmutableArray<T>, ImmutableList<T>, ImmutableDictionary<K, V>, ImmutableHashSet<T>, and friends. Each one has the property that no operation mutates the existing instance; "modifying" them returns a new instance, often sharing internal structure with the old one so the copy isn't a full duplicate.

A quick taste:

original.Add("Case") doesn't change original. It returns a new immutable array with the extra element. original still has two entries, updated has three.

These types pair naturally with immutable classes and records. Instead of storing a List<int> (mutable) inside an immutable Order, store an ImmutableArray<int> and skip the defensive copy entirely.

Quiz

Immutable Types Quiz

10 quizzes