How do you inject a protocol that has an associated type: primary associated types, any versus some, and generic view models?

15.8.2 · L5 · Interview

Predict first

Which dependencies can a view model hold, and how big are they?

protocol Repository<Model> {
    associatedtype Model
    func all() -> [Model]
}
struct User { var name: String }
struct MemoryUsers: Repository { func all() -> [User] { [User(name: "Asha")] } }
struct DiskUsers: Repository { func all() -> [User] { [User(name: "Ben"), User(name: "Chi")] } }

final class UserListModel {
    let repo: any Repository<User>            // existential with a constrained associated type
    init(repo: any Repository<User>) { self.repo = repo }
    var names: [String] { repo.all().map(\.name) }
}
struct GenericModel<R: Repository<User>> {    // generic: concrete type known to the compiler
    let repo: R
    var names: [String] { repo.all().map(\.name) }
}
func firstName(_ r: some Repository<User>) -> String? { r.all().first?.name }

let models = [UserListModel(repo: MemoryUsers()), UserListModel(repo: DiskUsers())]
print(models.map(\.names))
print(GenericModel(repo: DiskUsers()).names, firstName(MemoryUsers()) ?? "-")
print("sizes:", MemoryLayout<MemoryUsers>.size, MemoryLayout<GenericModel<MemoryUsers>>.size, MemoryLayout<(any Repository<User>)>.size)
  • [["Asha"], ["Ben", "Chi"]] / ["Ben", "Chi"] Asha / sizes: 0 0 40
  • The array of view models does not compile
  • sizes: 0 0 8

Predict first

Can an any Repository hold a repository of users?

protocol Repository<Model> { associatedtype Model; func all() -> [Model] }
struct User {}
struct Order {}
struct Users: Repository { func all() -> [User] { [] } }
let r: any Repository<Order> = Users()
  • No: the constrained associated type must match, so it does not compile
  • Yes, the constraint is only a hint
  • Yes, but it crashes when called

Mental model. A generic is a custom-fitted box built for exactly one kind of item. An existential is a universal box with a label on it: any item that matches the label fits, at the cost of the extra packaging.

A Repository with associatedtype Model cannot be used as a plain type: let repo: Repository is not allowed, because the compiler does not know what all() returns. There are three tools. Generics: struct GenericModel<R: Repository<User>> or a function taking some Repository<User> keeps the concrete type, so calls can be specialised and nothing is boxed, but the generic parameter spreads to everything that holds the model (a view that stores it becomes generic, arrays of them must share one R). Existentials: any Repository<User> works because the protocol declares a primary associated type (protocol Repository<Model>, Swift 5.7), which lets you constrain the associated type at the use site. In the check a list of two view models holds an in-memory and a disk repository together as any Repository<User>, which a generic parameter cannot do; the existential costs a box (40 bytes for the any value in the check, against zero for these empty structs) and dynamic dispatch. Type erasure (AnyRepository, written by hand) was the pre-5.7 answer and still appears in older code and in APIs that need a concrete, nameable type.

Rules of thumb for architecture. Use any Repository<User> at the boundary where the app is wired (composition root, view-model initialisers, SwiftUI environment values): it keeps types simple and tests can pass any conformer. Use generics inside a feature when you want the compiler to see the concrete type, or to avoid boxing in hot paths. The constraint is part of the type: an any Repository<Order> cannot hold a repository of users, and the compiler rejects it (the second check). If a protocol is only ever one-concrete-type-per-app, the extra generality is cost without benefit: prefer a plain struct over a protocol with an associated type that has a single conformer. Generic classes also cannot be stored in a heterogeneous collection or exposed to Objective-C, which matters when a view model must be referenced from UIKit code.

any Repository holds both conformers; the generic version fixes one concrete type; the box is 40 bytes.

A primary associated type constrains the existential like a generic requirement.

Common traps

  • Making every view model generic over its dependencies. The parameter leaks into every parent; use any at the wiring boundary.
  • Declaring associated types that only one type will ever fill. Use the concrete type until a second implementation exists.

They might follow up with

  • Do I still need AnyRepository? Rarely; primary associated types cover most cases, erasure remains for nameable types and older deployment targets.
  • Is any slower than generics? It adds a box and dynamic dispatch; measure before choosing for performance, and expect it to matter only in hot loops.

Related questions

  • 15.8.1 When is a struct of closures a better dependency than a protocol, and what do you give up?
  • 15.2.2 How do you do dependency injection in Swift, and when do you need a container or framework?
  • 1.4.6 What is an existential container? What is a witness table?

← When is a struct of closures a better dependency than a protocol, and…   How do you scope dependencies by lifetime (app, session, screen), and… →