Skip to content

Exercises - immutability patterns

Exercise 1🟢 Beginner Create an immutable Rectangle named tuple with width and height fields. Add methods that return new rectangles with scaled dimensions.

# expected:
# r1 = Rectangle(4, 6)
# r2 = r1.scale(2)
# r3 = r1.scale(0.5)
# r1.area() → 24
# r2.area() → 96 ← new rectangle, r1 unchanged
# r3.area() → 6
# r1 → Rectangle(width=4, height=6) ← untouched
# r2 → Rectangle(width=8, height=12)
# r3 → Rectangle(width=2.0, height=3.0)

Exercise 2🟢 Beginner Create an immutable Money named tuple with amount and currency fields. Add add and subtract methods that return new Money instances. Raise a ValueError if currencies don’t match.

# expected:
# m1 = Money(100, "EUR")
# m2 = Money(50, "EUR")
# m3 = Money(30, "USD")
# m1.add(m2) → Money(amount=150, currency='EUR')
# m1.subtract(m2) → Money(amount=50, currency='EUR')
# m1.add(m3) → ❌ ValueError: currency mismatch EUR vs USD
# m1 → Money(amount=100, currency='EUR') ← untouched

Exercise 3🟡 Intermediate Create an immutable Vector3D named tuple with x, y, z fields. Add add, scale, and dot product methods, all returning new instances where applicable.

# expected:
# v1 = Vector3D(1, 2, 3)
# v2 = Vector3D(4, 5, 6)
# v1.add(v2) → Vector3D(x=5, y=7, z=9)
# v1.scale(2) → Vector3D(x=2, y=4, z=6)
# v1.dot(v2) → 32 (1*4 + 2*5 + 3*6)
# v1 → Vector3D(x=1, y=2, z=3) ← untouched

Exercise 4🟢 Beginner Create a frozen User dataclass with name, email, and active fields. Add a deactivate and rename method that return new instances.

# expected:
# user = User("Alice", "alice@example.com", active=True)
# user.deactivate() → User(name='Alice', email='alice@example.com', active=False)
# user.rename("Alicia") → User(name='Alicia', email='alice@example.com', active=True)
# user → User(name='Alice', ..., active=True) ← untouched
# Attempting mutation:
# user.name = "Bob" → ❌ FrozenInstanceError

Exercise 5🟡 Intermediate Create a frozen HttpRequest dataclass with method, url, headers (as a tuple of tuples), and body fields. Add with_header and with_body methods that return new instances.

# expected:
# req = HttpRequest("GET", "https://api.example.com/users", headers=(), body=None)
# req.with_header("Authorization", "Bearer token123")
# → HttpRequest(method='GET', url='...', headers=(('Authorization', 'Bearer token123'),), body=None)
# req.with_body('{"name": "Alice"}')
# → HttpRequest(method='GET', url='...', headers=(), body='{"name": "Alice"}')
# req ← untouched

Exercise 6🟡 Intermediate Create a frozen Pipeline dataclass that holds a tuple of processing steps. Add an add_step method that returns a new Pipeline with the step appended, then execute the pipeline on an input value.

# expected:
# p1 = Pipeline(steps=())
# p2 = p1.add_step(str.strip)
# p3 = p2.add_step(str.lower)
# p4 = p3.add_step(str.title)
# p4.run(" HELLO WORLD ") → "Hello World"
# p1 → Pipeline(steps=()) ← untouched

Exercise 7🟢 Beginner Given a user dict, produce three new dicts — one with an updated email, one with a new field added, and one with the age field removed — without modifying the original.

user = {"name": "Alice", "email": "alice@old.com", "age": 30}
# expected:
# updated → {"name": "Alice", "email": "alice@new.com", "age": 30}
# extended → {"name": "Alice", "email": "alice@old.com", "age": 30, "role": "admin"}
# reduced → {"name": "Alice", "email": "alice@old.com"}
# user → {"name": "Alice", "email": "alice@old.com", "age": 30} ← untouched

Exercise 8🟡 Intermediate Write a function update_nested that updates a value in a nested dict without mutating any of the original dicts.

config = {
"database": {
"host": "localhost",
"port": 5432,
},
"cache": {
"host": "localhost",
"port": 6379,
}
}
# expected:
# update_nested(config, "database", "port", 9999)
# → {"database": {"host": "localhost", "port": 9999}, "cache": {...}}
# config["database"]["port"] → 5432 ← original untouched

Exercise 9🔴 Advanced Write a function deep_merge that merges two nested dicts immutably — nested keys are merged recursively, and the originals are never mutated.

defaults = {
"server": {"host": "localhost", "port": 8080, "debug": False},
"database": {"host": "localhost", "port": 5432},
}
overrides = {
"server": {"port": 3000, "debug": True},
"logging": {"level": "INFO"},
}
# expected:
# deep_merge(defaults, overrides) → {
# "server": {"host": "localhost", "port": 3000, "debug": True},
# "database": {"host": "localhost", "port": 5432},
# "logging": {"level": "INFO"},
# }
# defaults ← untouched
# overrides ← untouched

Exercise 10🟡 Intermediate Create an immutable ShoppingCart using a frozen dataclass that holds a tuple of items. Add add_item, remove_item, and total methods — all returning new instances where applicable.

# expected:
# cart = ShoppingCart(items=())
# cart1 = cart.add_item({"name": "apple", "price": 1.5})
# cart2 = cart1.add_item({"name": "banana", "price": 0.5})
# cart3 = cart2.add_item({"name": "cherry", "price": 3.0})
# cart3.total() → 5.0
# cart3.remove_item("banana") → ShoppingCart with apple and cherry only
# cart3.total() → 5.0 ← cart3 untouched
# cart → ShoppingCart(items=()) ← original untouched

Exercise 11🔴 Advanced Create an immutable EventLog using a NamedTuple that records a sequence of events as a tuple. Add append, filter_by_type, and replay methods — replay applies all events in order to an initial state dict and returns the final state.

# expected:
# log = EventLog(events=())
# log1 = log.append({"type": "set", "key": "name", "value": "Alice"})
# log2 = log1.append({"type": "set", "key": "score", "value": 0})
# log3 = log2.append({"type": "set", "key": "score", "value": 42})
# log4 = log3.append({"type": "delete","key": "name"})
# log4.replay({})
# → {"score": 42} ← name was deleted, score was set twice
# log4.filter_by_type("set").replay({})
# → {"name": "Alice", "score": 42} ← delete event excluded
# log → EventLog(events=()) ← original untouched

Try implementing every solution without ever calling .append(), update(), or any other mutating method on the original data — if you find yourself modifying in place, step back and return a new instance instead.