Keep the properties used by equality and hashing stable while an object is a dictionary key. For a local Swift calculation on your Mac, choose object identity, an immutable ID, or a value snapshot according to what the lookup should mean.

This walkthrough uses synthetic rectangles and small area calculations held entirely in memory. The practical task is to let a rectangle’s label or dimensions change without losing its associated dictionary entry—and without confusing a successful lookup with an up-to-date calculation.

Why can changing an object break a dictionary lookup?

Swift’s Dictionary is a hash table whose keys conform to Hashable. A key-based subscript returns an optional because the requested key might be absent. Hashing helps locate an entry; equality determines which keys count as equivalent. See Apple’s Dictionary documentation.

The Hashable contract connects those operations: equal instances must feed the same essential components into Hasher, in the same order. When implementing a custom key, use the properties that define equality in hash(into:). Apple describes this requirement in Hashable.

A reference-type key adds another consideration. Assigning a class instance passes a reference to the existing object rather than making an independent object. Declaring the reference with let does not freeze its mutable properties. These are language rules documented in Structures and Classes.

Suppose a rectangle class compares and hashes its editable label. You insert the object when its label is "Tile A", then change that label to "Tile B". The stored reference now exposes different key-defining state. The dictionary’s original placement and later lookup no longer have a stable agreement.

The design rule follows from the hash-table and equality contracts: keep the key’s equality-defining state stable throughout its membership. Apple states the corresponding requirement explicitly for Foundation objects: an object’s hash must not change while it belongs to a collection that uses that hash for placement. See NSObjectProtocol.hash.

Do not build a test around the assumption that every invalid mutation immediately returns nil. Once the key contract is broken, an apparently successful lookup is insufficient evidence that the design works. Test the intended equality rules and stable behavior instead.

Which key matches the task?

Choose the meaning of a dictionary entry before writing a conformance. For a rectangle-area tool, these three questions lead to different designs:

What should an entry represent? Suitable key What happens when the rectangle changes?
This exact rectangle object Identity-based class key The entry remains associated with that instance
A logical rectangle identified independently of its current object Immutable ID value The entry remains associated with the ID
A calculation for particular dimensions Immutable dimensions snapshot Changed dimensions produce a different lookup key

This table is a design aid derived from Swift’s identity, value, and hashing rules. It also separates two obligations: keeping an association reachable and keeping a calculated result current. A stable identity key solves the first; recalculation or a complete input snapshot addresses the second.

Use object identity when the entry belongs to one instance

For an in-memory association that should follow one object through edits, define equality with === and hash its ObjectIdentifier. Swift’s identity operator tests whether references point to the same instance. Apple documents ObjectIdentifier as a hashable identifier whose comparisons are valid during the instance’s lifetime. See Structures and Classes and ObjectIdentifier.

The following is a complete synchronous program. It needs a Mac with Swift command-line tools available; it uses only the automatically imported Swift standard library. Save it as IdentityKeys.swift and run swift IdentityKeys.swift in Terminal. The examples were inspected but could not be executed in the research environment because Swift was unavailable.

final class Rectangle: Hashable {
    var label: String
    var width: Int
    var height: Int

    init(label: String, width: Int, height: Int) {
        self.label = label
        self.width = width
        self.height = height
    }

    static func == (lhs: Rectangle, rhs: Rectangle) -> Bool {
        lhs === rhs
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(ObjectIdentifier(self))
    }
}

func demonstrateIdentityKeys() {
    let rectangle = Rectangle(label: "Tile A", width: 4, height: 3)
    let alias = rectangle
    let separate = Rectangle(label: "Tile A", width: 4, height: 3)

    var areas: [Rectangle: Int] = [
        rectangle: rectangle.width * rectangle.height
    ]

    rectangle.label = "Tile B"
    assert(areas[alias] == 12)
    assert(areas[separate] == nil)

    rectangle.width = 5
    assert(areas[rectangle] == 12) // Still the previous calculation.

    areas[rectangle] = rectangle.width * rectangle.height
    assert(areas[rectangle] == 15)
    assert(areas.count == 1)

    print("Renamed lookup: 12")
    print("Recalculated area: \(areas[rectangle]!)")
}

demonstrateIdentityKeys()

The expected output is Renamed lookup: 12, followed by Recalculated area: 15. The assertions express the useful contract: an alias finds the existing entry, a separately created rectangle does not, and editing the original object does not create another key.

The width change deliberately leaves the stored area at 12 until the program assigns 15. Identity remains stable, but the dictionary does not observe arithmetic dependencies or recompute values. Decide whether your application updates the value immediately after an edit or removes it until the next calculation.

Identity equality is a type-wide decision. Every == comparison on this class now means “same instance.” If the model already defines equality by dimensions or another value, use a separate key design instead of changing that meaning just for one dictionary.

You can also key a dictionary directly by ObjectIdentifier, but then the identifier alone does not provide the rectangle needed for later operations. Keep the relevant object alive for as long as its identifier is used for comparison, and remove obsolete entries. The documented lifetime limit makes this a scoped, in-memory identity mechanism. See ObjectIdentifier.

Use a value snapshot when the entry represents a calculation

For reusable area results, the dimensions themselves are often the appropriate key. Two rectangles measuring 4 by 3 can share the same result even if they are separate objects with different labels. Changing dimensions should select a different entry.

Swift structures have value semantics, and the compiler can synthesize Hashable when their stored properties are hashable. A snapshot containing immutable integers therefore provides a straightforward key for this calculation. See Structures and Classes and Hashable.

Run this complete alternative independently as swift SnapshotKeys.swift. Its synthetic inputs are positive, small integers, so the example does not need a general-purpose numeric input interface.

final class EditableRectangle {
    var label: String
    var width: Int
    var height: Int

    init(label: String, width: Int, height: Int) {
        self.label = label
        self.width = width
        self.height = height
    }
}

struct AreaInputs: Hashable, Sendable {
    let width: Int
    let height: Int
}

func demonstrateSnapshotKeys() {
    let rectangle = EditableRectangle(
        label: "Tile A", width: 4, height: 3
    )
    var areas: [AreaInputs: Int] = [:]

    let original = AreaInputs(
        width: rectangle.width, height: rectangle.height
    )
    areas[original] = original.width * original.height

    rectangle.label = "Tile B"
    rectangle.width = 5

    let current = AreaInputs(
        width: rectangle.width, height: rectangle.height
    )
    assert(areas[original] == 12)
    assert(areas[current] == nil)

    areas[current] = current.width * current.height
    assert(areas[current] == 15)
    assert(areas.count == 2)

    print("Original area: \(areas[original]!)")
    print("Current area: \(areas[current]!)")
}

demonstrateSnapshotKeys()

The expected output is Original area: 12, then Current area: 15. Both results remain reachable because the program inserted two distinct snapshots. The old result still correctly describes the old dimensions.

Include every input that affects the calculation in the snapshot. A label is irrelevant to rectangle area, so it is excluded here. If a later calculation depends on another setting, add that setting to the key. Otherwise, different calculations could incorrectly share an entry. This is a calculation-design requirement, independent of whether the key satisfies Hashable.

A structure that merely wraps a mutable class reference does not automatically create a frozen snapshot of that object. Copy the relevant values into the snapshot, as this example does. Also decide when to discard old snapshots if repeated edits would accumulate results you no longer need.

When should you use an immutable ID instead?

Use an ID when several object instances should address the same logical entry. For this synthetic tool, an immutable integer ID can be sufficient: store results in [Int: Int], use a rectangle’s let id: Int for access, and keep editable labels and dimensions outside the key.

Under that design, two objects with ID 7 intentionally address the same dictionary entry. Your construction rules must ensure that distinct logical rectangles do not accidentally receive the same ID. Updating an existing key replaces its associated value, as documented in Dictionary.

An ID-based area table still requires an update policy after dimensions change. If you want results for every distinct set of inputs, use snapshots. If you want only the latest result for each logical rectangle, an ID plus explicit recalculation is a better fit.

Do Foundation dictionaries or actors solve mutable keys?

Foundation has a different insertion contract. NSMutableDictionary.setObject(_:forKey:) requires an NSCopying key and copies it during insertion. A key copy can establish a snapshot when its copying implementation provides stable key state. Subsequent lookups must match that stored copy. See Apple’s setObject(_:forKey:).

Do not infer from this behavior that Swift’s native dictionary makes an independent copy of a class instance used as a key. Swift class assignment has reference semantics. For custom Foundation keys, verify the copying, equality, and hash behavior together; Foundation’s hash requirement still applies.

Concurrency is a separate concern. Actors coordinate access to their isolated mutable state, and Sendable describes values that can cross concurrency domains under its requirements. Neither changes the meaning of a key’s equality or repairs a key whose defining state changes after insertion. See Swift’s Concurrency chapter and Apple’s Sendable documentation.

The snapshot example explicitly declares Sendable because its stored integers satisfy the protocol’s value-type requirements. If you later move area calculations into an actor, send a complete snapshot and let the actor own its results dictionary. The mutable rectangle can remain within its existing isolation domain. These synchronous examples need no actor.

Check the contract before relying on a lookup

Use a small, deterministic sequence to validate the chosen design:

  1. Insert an entry and confirm its initial lookup.
  2. Change a property excluded from equality and hashing; confirm the same entry remains reachable.
  3. Compare an alias with a separately created object or snapshot; confirm the results match the intended meaning of equality.
  4. Change a calculation input; confirm the result is either refreshed, removed, or accessed through a new snapshot.
  5. Check the dictionary count so replacement and insertion behave as intended.

If an existing implementation mutates key-defining state, fix the key model before relying on further lookups. For this small local calculation, rebuilding results from a separate list of synthetic inputs is clearer than treating later dictionary operations as a repair mechanism. Preserve enough original input to recompute rather than silently dropping an entry you cannot find.

Choose identity for an association with one live object, an immutable ID for one logical item, and a value snapshot for one calculation. Then make the result-update policy explicit. Together, those decisions keep lookups meaningful as the editable rectangle changes.

Sources