Swift

Swift Generics Advanced + Protocol Associated Types: From “Can Use” to “Can Design”

By Seren  |  23 Sep, 2026  |  Leave a comment


I did not start using advanced Swift generics because I wanted a more abstract architecture. I used them because a synchronization feature allowed the wrong local cache and remote source to be connected. Both objects had methods named load and save, so the mistake looked reasonable until data reached the wrong layer.

The fix was not a forced cast or another runtime check. I made the model type part of each protocol’s contract, then required the two associated types to be equal. The compiler could finally express the rule the project already depended on: a task repository can synchronize only with a task source.

The Sync Feature That Was Too Loosely Typed

The feature had a local store, a remote API adapter, and a coordinator. The coordinator fetched records, compared them with cached values, saved incoming changes, and produced a small report. We needed the same workflow for tasks, notes, and user preferences.

An early design passed values around as a broad base protocol and recovered concrete types with casts. That made the coordinator reusable on paper, but it moved an important compatibility rule to runtime. A mismatched pair compiled and failed only when the sync operation was exercised.

I wanted the API itself to reject that wiring. This is where an associated type became useful: it lets a protocol say that each conforming type has one model type, even though the protocol does not choose that type in advance.

The Entity Contract Starts the Type Chain

Every synchronized record needs a stable identifier, and the identifier must be usable as a dictionary key. The entity protocol declares that relationship:

protocol Entity: Codable, Equatable {
    associatedtype ID: Hashable
    var id: ID { get }
}

ID is not a value waiting to be filled at runtime. It is a type selected by each conforming entity. A task may use String, while another model may use UUID. Requiring Hashable makes the later dictionary storage valid for every conforming entity.

This is the first design improvement over a generic bag of values. The identifier relationship is visible to the compiler and to anyone reading the protocol. There is no need for a comment promising that an ID will happen to be hashable.

Associated Types Describe Each Data Provider

The local and remote sides each declare the entity they handle:

protocol Repository {
    associatedtype Model: Entity
    func all() -> [Model]
    func save(_ models: [Model])
}

protocol RemoteSource {
    associatedtype Model: Entity
    func fetch() throws -> [Model]
}

A concrete repository does not need to write a separate type declaration if Swift can infer Model from the methods it implements. The generic in-memory repository in the sample makes the choice explicit through its own generic parameter.

I reach for associatedtype when the type belongs to a conformer’s identity. A repository is not merely able to process one arbitrary model per call; it is a repository for a particular model. A generic method is a better fit when the caller should choose a potentially different type on every invocation.

The where Clause Enforces the Connection

The two protocols are useful separately, but the project also needs to describe their relationship. The sync engine adds a same-type requirement:

struct SyncEngine<Local: Repository, Remote: RemoteSource>
where Local.Model == Remote.Model {
    let local: Local
    let remote: Remote
}

The generic parameters preserve the concrete local and remote types. The where clause then says their models must be identical. Creating an engine with a task cache and a note source is now a compile-time error. The engine’s implementation can fetch remote models and pass them directly to local.save without a cast.

A where clause is not inherently more advanced than an inline constraint. It becomes valuable when the rule relates nested or associated types, or when writing all constraints beside the parameter list would obscure the main declaration.

A Generic Repository Without Type Erasure

The demo stores entities in a dictionary keyed by their associated identifier:

final class InMemoryRepository<Model: Entity>: Repository {
    private var storage: [Model.ID: Model]

    init(seed: [Model] = []) {
        storage = Dictionary(
            uniqueKeysWithValues: seed.map { ($0.id, $0) }
        )
    }

    func all() -> [Model] {
        Array(storage.values)
    }

    func save(_ models: [Model]) {
        for model in models {
            storage[model.id] = model
        }
    }
}

One implementation now works for any entity whose ID satisfies the protocol constraint. The code does not know whether the key is a string or UUID. It only relies on the operations promised by Hashable.

This is the kind of abstraction I find worth keeping: the generic removes duplicated storage logic while preserving useful type information. If an abstraction merely shortens a type name but makes errors harder to read, I do not consider it an improvement.

How the Synchronization Report Is Built

The engine indexes cached records by ID, fetches incoming records, and classifies each one as inserted, updated, or unchanged. It then saves the incoming values. The important line is ordinary because the generic constraints already did the difficult work:

let incoming = try remote.fetch()
local.save(incoming)

incoming has type [Remote.Model], while save expects [Local.Model]. The same-type requirement proves those arrays are compatible. Without that relationship, the coordinator would need another conversion layer or a runtime assertion.

The report also carries the entity’s ID type as a generic parameter. This lets callers receive task IDs as strings without erasing them into AnyHashable or converting everything to text.

Choosing Between Generics, some, and any

Modern Swift can use protocols with associated types as existential values through the any spelling. That does not make every operation equally convenient. An existential intentionally hides its concrete conforming type, so relationships involving its associated model may be unavailable or require opening the existential.

I use a generic parameter when a function or type must preserve relationships between concrete types, as the sync engine does. I use some Repository when an API returns one concrete repository implementation but should hide its name from the caller. The demo includes a factory with an opaque return type and verifies that its repository still works.

I use any Repository when I genuinely need runtime heterogeneity or storage behind one existential interface and can accept the hidden type information. Type erasure is another option when I need a stable custom interface, but it adds forwarding code and should solve a real storage boundary rather than being added by habit.

What I Check Before Adding More Generics

  • Does the abstraction remove duplicated behavior across real types?
  • Is there a type relationship the compiler should enforce?
  • Does an associated type belong to the conformer’s identity?
  • Would a generic method be simpler because the caller chooses the type?
  • Do I need to preserve concrete relationships, return one hidden type, or store mixed values?
  • Are compiler errors still understandable at the call site?
  • Can a small test prove the intended wiring and behavior?

The sync bug changed my view of advanced generics. Their value was not clever syntax or theoretical performance. They gave a business rule a place in the type system. The result became harder to connect incorrectly and easier to read once the names reflected the roles in the project.

Download and Run the Example

The complete example is a Swift Package for macOS and requires Swift 5.9 or later:

git clone https://github.com/2252408699/swift-generic-sync-engine.git
cd swift-generic-sync-engine
swift run

The executable synchronizes three task records and prints seven PASS lines followed by All generic sync checks passed.

Complete source code: swift-generic-sync-engine on GitHub

Seren
Seren

A developer exploring iOS, Objective-C, Swift, and other tech stacks. Here to document my learning process, pitfalls, and growing journey across new technologies.

Your email address will not be published. Required fields are marked *