Immutable Objects — Unchanging by Design
Designing Immutable Classes
Read aloud is unavailable in this browser.
From Theory to Practice
Understanding why immutability is valuable is half the battle. The other half is knowing how to design immutable classes correctly and efficiently. This isn't always straightforward—each language has its own idioms, and there are subtle pitfalls that can break immutability despite your best intentions.
This page provides a comprehensive guide to designing immutable classes. We'll cover the fundamental rules, language-specific patterns, and techniques for handling complex scenarios like nested objects and collections.
By the end of this page, you will know the rules for creating truly immutable classes, master language-specific idioms (Python, Java, TypeScript), understand how to handle mutable fields safely, learn the builder pattern for complex construction, and be able to implement functional update methods.
The Rules of Immutability
Creating a truly immutable class requires following several rules. Missing any one of them creates a hole through which mutability can leak.
The Five Rules of Immutable Class Design:
- •Make all fields private and final — Fields should be set once in the constructor and never modified. Use language-specific keywords like
final(Java),readonly(TypeScript/C#), or underscore naming conventions (Python). - •Don't provide setter methods — Any method that modifies state breaks immutability. Operations that would "change" the object should return new instances instead.
- •Prevent subclassing — Subclasses could add mutable state or override methods to introduce mutation. Make the class
final(Java) or use other mechanisms to prevent extension. - •Ensure deep immutability — If fields reference objects, those objects must also be immutable. Mutable references allow mutation to "leak through."
- •Use defensive copying — When accepting or returning mutable objects (like collections), copy them to prevent external mutation of internal state.
Let's see these rules applied across different languages:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
import java.util.List;import java.util.ArrayList;import java.util.Collections;import java.util.Objects; /** * A properly designed immutable class in Java. * * ✓ Class is final — cannot be subclassed * ✓ All fields are private and final * ✓ No setter methods * ✓ Defensive copying for mutable inputs * ✓ Defensive copying (or unmodifiable view) for outputs */public final class ImmutablePerson { // RULE 1: All fields private and final private final String name; private final int age; private final List<String> hobbies; // Mutable type — requires special handling // RULE 5: Defensive copy on construction public ImmutablePerson(String name, int age, List<String> hobbies) { this.name = name; // String is immutable — no copy needed this.age = age; // Primitives are immutable by nature // CRITICAL: Defensive copy of mutable collection // Without this, caller could modify our internal state! this.hobbies = new ArrayList<>(hobbies); } // RULE 2: No setters — only getters public String getName() { return name; // String is immutable — direct return is safe } public int getAge() { return age; // Primitive — direct return is safe } public List<String> getHobbies() { // RULE 5: Return unmodifiable view — prevents external mutation // Alternative: return new ArrayList<>(hobbies) — defensive copy return Collections.unmodifiableList(hobbies); } // RULE 2: "Modification" methods return NEW objects public ImmutablePerson withName(String newName) { return new ImmutablePerson(newName, this.age, this.hobbies); } public ImmutablePerson withAge(int newAge) { return new ImmutablePerson(this.name, newAge, this.hobbies); } public ImmutablePerson withHobby(String newHobby) { List<String> newHobbies = new ArrayList<>(this.hobbies); newHobbies.add(newHobby); return new ImmutablePerson(this.name, this.age, newHobbies); } // Value-based equality for immutable objects @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ImmutablePerson that = (ImmutablePerson) o; return age == that.age && Objects.equals(name, that.name) && Objects.equals(hobbies, that.hobbies); } @Override public int hashCode() { return Objects.hash(name, age, hobbies); } @Override public String toString() { return "ImmutablePerson{name='" + name + "', age=" + age + ", hobbies=" + hobbies + "}"; }} // Usage example:ImmutablePerson alice = new ImmutablePerson( "Alice", 30, List.of("Reading", "Hiking")); // "Modification" creates new object; original unchangedImmutablePerson olderAlice = alice.withAge(31);ImmutablePerson aliceWithNewHobby = alice.withHobby("Cooking"); System.out.println(alice); // age=30, hobbies=[Reading, Hiking]System.out.println(olderAlice); // age=31System.out.println(aliceWithNewHobby); // hobbies=[Reading, Hiking, Cooking]Handling Nested Mutable Objects
The biggest challenge in designing immutable classes is handling nested objects. When your immutable class contains references to other objects, you must ensure those objects are also immutable—or use defensive copying to isolate them.
Strategy 1: Use Only Immutable Types
The cleanest approach is to compose your immutable class entirely from other immutable types:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
from dataclasses import dataclassfrom typing import Tuple # All components are immutable@dataclass(frozen=True)class Address: street: str city: str postal_code: str country: str @dataclass(frozen=True)class ContactInfo: email: str phone: str @dataclass(frozen=True)class Person: """ Composed entirely of immutable types: - str, int — primitives - Address, ContactInfo — frozen dataclasses - Tuple — immutable sequence """ name: str age: int address: Address # Immutable nested object contact: ContactInfo # Immutable nested object tags: Tuple[str, ...] # Immutable collection # Update methods create new instances at every level def with_address(self, new_address: Address) -> 'Person': return Person( self.name, self.age, new_address, self.contact, self.tags ) def with_city(self, new_city: str) -> 'Person': """Update nested value — creates new Address AND new Person""" new_address = Address( self.address.street, new_city, # Only city changes self.address.postal_code, self.address.country ) return self.with_address(new_address) def with_tag(self, tag: str) -> 'Person': return Person( self.name, self.age, self.address, self.contact, self.tags + (tag,) ) # Usage:alice = Person( name="Alice", age=30, address=Address("123 Main St", "Boston", "02101", "USA"), contact=ContactInfo("alice@example.com", "555-1234"), tags=("developer", "team-lead")) # Update nested city — creates new Address in new Personalice_in_seattle = alice.with_city("Seattle") # Original completely unchangedprint(f"Original city: {alice.address.city}") # Bostonprint(f"New city: {alice_in_seattle.address.city}") # Seattle # The original alice object is frozen foreverStrategy 2: Deep Defensive Copying
When you must work with mutable types, use deep defensive copying on both input and output:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
import copyfrom typing import Dict, List, Any class ImmutableConfig: """ Contains nested mutable structures (dicts, lists). Uses deep defensive copying for protection. """ def __init__(self, settings: Dict[str, Any]): # DEEP copy on input — isolate from external changes self._settings = copy.deepcopy(settings) # Recursively freeze the structure self._settings = self._deep_freeze(self._settings) def _deep_freeze(self, obj): """Convert mutable containers to immutable equivalents""" if isinstance(obj, dict): return tuple((k, self._deep_freeze(v)) for k, v in sorted(obj.items())) elif isinstance(obj, list): return tuple(self._deep_freeze(item) for item in obj) elif isinstance(obj, set): return frozenset(self._deep_freeze(item) for item in obj) else: return obj # Primitive or already immutable def get(self, *keys): """Navigate nested structure safely""" current = dict(self._settings) for key in keys: if isinstance(current, dict): current = current.get(key) elif isinstance(current, tuple): # Handle frozen dict (tuple of pairs) current = dict(current).get(key) else: current = None if current is None: return None return current def to_dict(self) -> Dict[str, Any]: """Return a mutable COPY — caller can modify without affecting us""" return self._unfreeze(self._settings) def _unfreeze(self, obj): """Convert back to mutable structures (for output copy)""" if isinstance(obj, tuple): # Check if it's a frozen dict (tuple of pairs) if obj and isinstance(obj[0], tuple) and len(obj[0]) == 2: return {k: self._unfreeze(v) for k, v in obj} else: return [self._unfreeze(item) for item in obj] elif isinstance(obj, frozenset): return set(self._unfreeze(item) for item in obj) else: return obj def with_setting(self, key: str, value: Any) -> 'ImmutableConfig': """Return new config with updated setting""" new_settings = self.to_dict() new_settings[key] = value return ImmutableConfig(new_settings) def __hash__(self): return hash(self._settings) def __eq__(self, other): return isinstance(other, ImmutableConfig) and self._settings == other._settings # Usage:original_dict = { "database": { "host": "localhost", "ports": [5432, 5433], "credentials": {"user": "admin", "pass": "secret"} }, "cache": {"ttl": 300}} config = ImmutableConfig(original_dict) # External modification doesn't affect configoriginal_dict["database"]["host"] = "hacked!"print(f"Config still has: {config.to_dict()['database']['host']}") # localhost # Modification returns new configconfig2 = config.with_setting("cache", {"ttl": 600})print(f"Original TTL: {config.to_dict()['cache']['ttl']}") # 300print(f"New TTL: {config2.to_dict()['cache']['ttl']}") # 600Deep defensive copying can be expensive for large nested structures. For performance-critical code, prefer genuinely immutable types or structural sharing libraries. Reserve deep copying for situations where you must interface with mutable external data.
The Builder Pattern for Immutable Objects
When immutable objects have many fields, constructors become unwieldy. The Builder Pattern solves this by providing a fluent, mutable builder that produces an immutable object at the end.
Why Builders Work Well with Immutability:
- Builders are mutable, allowing incremental construction
- The final
build()call produces an immutable object - Named methods are clearer than positional constructor arguments
- Optional fields have sensible defaults
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
import java.util.List;import java.util.ArrayList;import java.util.Collections;import java.time.LocalDate; /** * Immutable class with many fields — uses Builder pattern */public final class ImmutableOrder { private final String orderId; private final String customerId; private final List<String> items; private final double total; private final String shippingAddress; private final String billingAddress; private final LocalDate orderDate; private final String couponCode; private final boolean giftWrap; private final String giftMessage; // Private constructor — only accessible via Builder private ImmutableOrder(Builder builder) { this.orderId = builder.orderId; this.customerId = builder.customerId; this.items = Collections.unmodifiableList(new ArrayList<>(builder.items)); this.total = builder.total; this.shippingAddress = builder.shippingAddress; this.billingAddress = builder.billingAddress; this.orderDate = builder.orderDate; this.couponCode = builder.couponCode; this.giftWrap = builder.giftWrap; this.giftMessage = builder.giftMessage; } // Only getters, no setters public String getOrderId() { return orderId; } public String getCustomerId() { return customerId; } public List<String> getItems() { return items; } public double getTotal() { return total; } public String getShippingAddress() { return shippingAddress; } public String getBillingAddress() { return billingAddress; } public LocalDate getOrderDate() { return orderDate; } public String getCouponCode() { return couponCode; } public boolean isGiftWrap() { return giftWrap; } public String getGiftMessage() { return giftMessage; } // Static factory to get Builder public static Builder builder() { return new Builder(); } // toBuilder() for creating modified copies public Builder toBuilder() { return new Builder() .orderId(this.orderId) .customerId(this.customerId) .items(new ArrayList<>(this.items)) .total(this.total) .shippingAddress(this.shippingAddress) .billingAddress(this.billingAddress) .orderDate(this.orderDate) .couponCode(this.couponCode) .giftWrap(this.giftWrap) .giftMessage(this.giftMessage); } /** * Mutable Builder class — accumulates state, then produces immutable result */ public static class Builder { private String orderId; private String customerId; private List<String> items = new ArrayList<>(); private double total; private String shippingAddress; private String billingAddress; private LocalDate orderDate = LocalDate.now(); // Default private String couponCode = null; // Optional private boolean giftWrap = false; // Default private String giftMessage = null; // Optional public Builder orderId(String orderId) { this.orderId = orderId; return this; } public Builder customerId(String customerId) { this.customerId = customerId; return this; } public Builder items(List<String> items) { this.items = new ArrayList<>(items); return this; } public Builder addItem(String item) { this.items.add(item); return this; } public Builder total(double total) { this.total = total; return this; } public Builder shippingAddress(String address) { this.shippingAddress = address; return this; } public Builder billingAddress(String address) { this.billingAddress = address; return this; } public Builder orderDate(LocalDate date) { this.orderDate = date; return this; } public Builder couponCode(String code) { this.couponCode = code; return this; } public Builder giftWrap(boolean giftWrap) { this.giftWrap = giftWrap; return this; } public Builder giftMessage(String message) { this.giftMessage = message; return this; } // Terminal operation — produces immutable object public ImmutableOrder build() { // Validation before building if (orderId == null) throw new IllegalStateException("orderId required"); if (customerId == null) throw new IllegalStateException("customerId required"); if (shippingAddress == null) throw new IllegalStateException("shippingAddress required"); return new ImmutableOrder(this); } }} // Usage — clean, readable constructionImmutableOrder order = ImmutableOrder.builder() .orderId("ORD-12345") .customerId("CUST-001") .addItem("Widget") .addItem("Gadget") .total(99.99) .shippingAddress("123 Main St, Boston") .billingAddress("123 Main St, Boston") .couponCode("SAVE10") .giftWrap(true) .giftMessage("Happy Birthday!") .build(); // Create modified copy using toBuilder()ImmutableOrder updatedOrder = order.toBuilder() .addItem("Extra Item") .total(129.99) .build(); // Original is unchangedSystem.out.println("Original items: " + order.getItems().size()); // 2System.out.println("Updated items: " + updatedOrder.getItems().size()); // 3Writing builders manually is tedious. Libraries like Lombok (Java's @Builder), attrs (Python's @define with frozen=True), and various TypeScript immutability libraries can generate these patterns automatically. Use them in production code!
Functional Update Patterns
The with* methods we've seen are examples of functional updates—methods that return new objects with some values changed. Let's formalize this pattern and explore its variations.
The with Method Convention:
The naming convention withPropertyName(newValue) is widely used for functional updates. It clearly communicates: "give me a copy of this object with this property changed."
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
from dataclasses import dataclass, replacefrom typing import TypeVar, Callable # Python's dataclasses have a built-in functional update: replace() @dataclass(frozen=True)class User: id: str name: str email: str role: str active: bool login_count: int # Using dataclasses.replace() for functional updatesalice = User("1", "Alice", "alice@example.com", "admin", True, 42) # replace() creates a new instance with specified fields changedalice_v2 = replace(alice, name="Alice Smith")alice_v3 = replace(alice, role="superadmin", login_count=43) print(f"Original: {alice.name}, {alice.role}") # Alice, adminprint(f"Updated: {alice_v2.name}, {alice_v3.role}") # Alice Smith, superadmin # ───────────────────────────────────────────────────── # Pattern: Transform functions for complex updates @dataclass(frozen=True)class Account: id: str balance: float status: str transactions: tuple # Simple updates def with_balance(self, new_balance: float) -> 'Account': return replace(self, balance=new_balance) def with_status(self, new_status: str) -> 'Account': return replace(self, status=new_status) # Derived updates (transform based on current state) def credit(self, amount: float) -> 'Account': return self.with_balance(self.balance + amount) def debit(self, amount: float) -> 'Account': if amount > self.balance: raise ValueError("Insufficient funds") return self.with_balance(self.balance - amount) # Updates with side-data def with_transaction(self, tx: str) -> 'Account': return replace(self, transactions=self.transactions + (tx,) ) # Compound updates def apply_interest(self, rate: float) -> 'Account': interest = self.balance * rate return (self .credit(interest) .with_transaction(f"Interest applied: {interest:.2f}")) # Conditional updates def close(self) -> 'Account': if self.balance != 0: raise ValueError("Cannot close account with non-zero balance") return self.with_status("closed") # Usage — chain functional updatesaccount = Account("ACC-001", 1000.0, "active", ()) account = (account .credit(500) .with_transaction("Deposit: 500") .apply_interest(0.01) # 1% interest .debit(100) .with_transaction("Withdrawal: 100")) print(f"Final balance: {account.balance}")print(f"Transactions: {account.transactions}") # ───────────────────────────────────────────────────── # Pattern: Lens-like updates for nested structures @dataclass(frozen=True)class Address: city: str street: str @dataclass(frozen=True)class Profile: name: str address: Address def with_city(self, city: str) -> 'Profile': """Update nested city through Profile""" return replace(self, address=replace(self.address, city=city)) def with_street(self, street: str) -> 'Profile': return replace(self, address=replace(self.address, street=street)) # Deeply nested update made simpleprofile = Profile("Alice", Address("Boston", "123 Main"))profile = profile.with_city("Seattle") print(f"New city: {profile.address.city}") # Seattle- •Name methods clearly —
withX,plusItem,minusItem,transformed, etc. convey intent - •Return the same type — Functional updates should return the same type for chaining
- •Support chaining — Design methods so they can be composed:
a.withX(1).withY(2).withZ(3) - •Provide derived updates — Not just
withBalance(n)but alsocredit(n)anddebit(n) - •Wrap nested updates — Provide
withCity(c)even ifaddressis nested inside - •Consider
toBuilder()— For many changes, converting to builder and back is cleaner
Summary: Designing Immutable Classes
We've covered the practical techniques for designing immutable classes. Let's consolidate:
- •Follow the five rules — Private/final fields, no setters, prevent subclassing, deep immutability, defensive copying.
- •Prefer immutable nested types — Compose from other immutable classes for true deep immutability.
- •Defensive copy when necessary — When interfacing with mutable types, copy on input and output.
- •Use builders for complex objects — The Builder pattern makes construction of many-field objects clean and safe.
- •Design functional update APIs —
withX(newValue)methods enable clean, chainable modifications. - •Leverage language features — Use
frozen=True,final,readonly,Object.freeze(), and records where available.
What's Next:
No design is without trade-offs. The final page examines the costs of immutability—when it adds complexity, when performance matters, and when mutable designs might be the right choice. Understanding these trade-offs helps you make informed decisions about when to apply immutability.
You now have the practical skills to design immutable classes in Python, Java, and TypeScript. These patterns will serve you across virtually any object-oriented codebase.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.