Make custom Swift equality and hashing use one immutable value definition. This walkthrough builds a local tile key whose name ignores letter case while its dimensions remain exact, then checks equality, set membership, and dictionary lookup.
Define exactly when two tiles are the same
Suppose you are building a small Mac desktop graphics exercise. You need to group synthetic tile descriptions by name, width, and height. The reader task is to implement that grouping consistently: Orbit and ORBIT at the same dimensions should produce equal keys; changing either dimension should produce a different key.
Write the contract before implementing either protocol. For this example, two keys are equal when their case-folded names, widths, and heights are equal. Dimensions are positive integer drawing units. Leading and trailing spaces remain significant. This is an illustrative application rule, rather than a requirement imposed by Swift.
| Synthetic inputs | Expected relationship | Reason |
|---|---|---|
Orbit, 40 × 20 and ORBIT, 40 × 20 |
Equal | Letter case is ignored |
Orbit, 40 × 20 and orbit, 41 × 20 |
Unequal | Width participates in equality |
Orbit, 40 × 20 and orbit, 40 × 21 |
Unequal | Height participates in equality |
Orbit, 40 × 20 and orbit , 40 × 20 |
Unequal | The trailing space is preserved |
Use a dedicated key when a particular grouping rule differs from the full value of a displayed tile. Keep original display spelling in the surrounding presentation model. That avoids declaring two complete tile descriptions interchangeable while exposing visibly different names as part of their value. Swift’s Equatable documentation describes equality in terms of substitutability and requires reflexivity, symmetry, and transitivity. Equatable
Give equality and hashing the same components
Swift’s Hashable protocol inherits from Equatable. Its essential requirement is that equal instances feed the same values into the hasher in the same order. Hashable values can serve as set elements and dictionary keys. Hashable
A frequent mistake is to compare normalized names in == but hash the original spelling. The names then have different meanings in the two methods. Another mistake is to omit width from equality while including it in hashing. Both designs allow values declared equal to supply different hashing inputs. Apple explicitly directs custom implementations to use the same significant properties in both methods. Adopting Common Protocols
For this example, put the equality components into a private nested structure. Let Swift synthesize that structure’s equality and hashing, then delegate both outer operations to it. This makes the component list a single declaration you can inspect. Swift supports synthesized conformance for structures whose stored properties conform to the required protocols. Protocols — Adopting a Protocol Using a Synthesized Implementation
If your type already stores exactly its equality components, synthesized Hashable conformance alone is usually sufficient. The explicit outer methods below demonstrate how to keep an existing custom implementation aligned without maintaining two independent field lists.
Run the complete local example on your Mac
You need an installed Swift toolchain with Foundation. In Terminal, check it with swift --version, as recommended by Swift’s getting-started documentation. If Swift is unavailable, follow the official macOS installation guide before continuing. Getting Started, Install Swift — macOS
The following is one complete shell command containing the Swift program. Paste the whole block into Terminal. It supplies the program through standard input; the example performs only in-memory calculations and prints results. The Swift code was inspected but could not be executed in the research environment because Swift was unavailable.
swift - <<'SWIFT'
import Foundation
struct TileKey: Hashable, Sendable {
private struct Components: Hashable, Sendable {
let foldedName: String
let width: Int
let height: Int
}
private let components: Components
init(name: String, width: Int, height: Int) {
precondition(width > 0 && height > 0,
"Tile dimensions must be positive")
let foldedName = name.folding(
options: [.caseInsensitive],
locale: Locale(identifier: "en_US_POSIX")
)
components = Components(
foldedName: foldedName,
width: width,
height: height
)
}
static func == (lhs: TileKey, rhs: TileKey) -> Bool {
lhs.components == rhs.components
}
func hash(into hasher: inout Hasher) {
hasher.combine(components)
}
}
let a = TileKey(name: "Orbit", width: 40, height: 20)
let b = TileKey(name: "ORBIT", width: 40, height: 20)
let c = TileKey(name: "orbit", width: 40, height: 20)
let wider = TileKey(name: "orbit", width: 41, height: 20)
let taller = TileKey(name: "orbit", width: 40, height: 21)
let spaced = TileKey(name: "orbit ", width: 40, height: 20)
let samples = [a, b, c, wider, taller, spaced]
precondition(a == b && b == c && a == c)
precondition(a != wider && a != taller && a != spaced)
// Check the equality laws over this finite sample.
for first in samples {
precondition(first == first, "Reflexivity failed")
for second in samples {
precondition((first == second) == (second == first),
"Symmetry failed")
if first == second {
precondition(first.hashValue == second.hashValue,
"Equal values have different hashes")
}
for third in samples where first == second && second == third {
precondition(first == third, "Transitivity failed")
}
}
}
let uniqueKeys = Set(samples)
precondition(uniqueKeys.count == 4)
precondition(uniqueKeys.contains(b))
let patterns = [a: "striped"]
precondition(patterns[b] == "striped")
print("Unique keys:", uniqueKeys.count)
print("Equivalent key found:", uniqueKeys.contains(b))
print("Pattern:", patterns[b] ?? "missing")
print("Sample contract checks passed")
SWIFT
Expected output:
Unique keys: 4
Equivalent key found: true
Pattern: striped
Sample contract checks passed
The six inputs form four groups: the three case variants share one key, while the changed width, changed height, and trailing-space variant each form another. The dictionary check asks whether a value associated with a can be retrieved using the equal key b. These checks exercise the intended collection behavior without depending on any particular numeric hash.
The initializer deliberately stops on nonpositive dimensions. That is appropriate for this fixed synthetic exercise, where invalid dimensions indicate a programming mistake. If a desktop interface permits incomplete dimension entry, validate that input before constructing the key, or change the initializer to return an optional or throw an error. Do not silently substitute a dimension, because that changes the equality contract.
Make text normalization an explicit rule
The normalization step calls Foundation’s folding(options:locale:) through Swift’s string API. The API returns a string with the specified folding options applied. Here, only .caseInsensitive is requested. StringProtocol.folding(options:locale:)
Foundation documents case, width, and diacritic folding as separate choices. It also explains that locale can change folding results and that folded text may be unsuitable for display. The example therefore supplies a fixed locale and stores the folded result only inside the key. Its synthetic names are deliberately simple. This locale choice defines this exercise’s behavior; it is not a universal recommendation for every naming interface. NSString.folding(options:locale:)
The useful design decision is to normalize once during construction. Equality and hashing subsequently use the stored result, so neither operation independently chooses a locale or transformation. If the application later needs a different comparison policy, construct keys under that new policy and rebuild the affected in-memory collection.
Before broadening normalization, add examples that distinguish the desired groups. Should spaces matter? Should accented letters remain distinct? Should visually different character widths count as the same name? Answer those questions as product rules, then implement one transformation used by both operations.
Diagnose a mismatch without trusting hash numbers
Use the following sequence when adapting this design to an existing type:
- Write one pair that must compare equal and one pair for each field that must distinguish values.
- Trace the actual values compared by
==, including normalization and derived properties. - Trace the values supplied to
hasher.combine, including their order. - Move the shared definition into one immutable component value, or make both implementations explicitly use the same components.
- Check equality laws, equal-value hashes, set membership, and dictionary lookup using the same fixtures.
Do not implement equality by comparing hashValue. Equal values must have equal hashes, but a matching hash does not establish equality. The full equality components remain the basis for ==. Likewise, a test that requires every unequal pair to have different hashes demands more than the hashing contract guarantees. Hashable
Compare hashes only within the current execution when checking this contract. Swift’s Hasher uses randomized seeding, and its algorithm may change between standard-library versions. A printed integer from yesterday’s run is therefore unsuitable as an expected result for today’s run. Hasher
The finite sample checks are useful regression checks, not a proof for every possible string and dimension. The structural argument matters too: both outer methods delegate to the same immutable Components value, whose stored fields fully express the chosen contract.
Keep concurrency requirements separate from equality
The example declares both structures Sendable because their stored values are strings, integers, and another sendable structure. Swift documents that sendable structures must contain sendable stored properties. Sendability concerns transferring values between concurrency domains; it does not define which values compare equal. Sendable, The Swift Programming Language — Concurrency
This local calculation needs no tasks or actors. If it later becomes part of concurrent desktop code, keep equality and hashing as synchronous operations on the key’s stored components. Treat any shared mutable collection as a separate concurrency design concern. The language guide describes actors and isolation as mechanisms for coordinating access to mutable state. The Swift Programming Language — Concurrency
There is also a Foundation boundary when adapting the pattern: an NSObject subclass uses inherited equality and hashing behavior. Apple’s supported customization path is to override isEqual(_:) and hash together. The plain Swift structure in this example uses == and hash(into:). Adopting Common Protocols
For a local tile key, the maintainable result is a written comparison rule, one immutable representation of that rule, and checks that exercise actual collection lookups. When the definition changes, update the component representation and its examples together so equality and hashing continue to describe the same value.
Sources
- Hashable — Apple
- Equatable — Apple
- Hasher — Apple
- Adopting Common Protocols — Apple
- folding(options:locale:) — StringProtocol — Apple
- folding(options:locale:) — NSString — Apple
- Sendable — Apple
- Protocols — The Swift Programming Language, Swift.org
- Concurrency — The Swift Programming Language, Swift.org
- Getting Started — Swift.org
- Install Swift — macOS — Swift.org