Protocols are often introduced as Swift’s version of an interface. That explanation is fine for the first ten minutes, then it starts to get in the way.
The interesting protocols are not just lists of methods. They describe a relationship between a type and the values it works with. A cache has a value type. A repository has an entity type. A renderer has input and output types. If we force all of those relationships into Any, we trade the compiler’s help for casts and runtime surprises.
associatedtype is the feature that lets a protocol carry that relationship without deciding the concrete type too early. It is also the reason a perfectly reasonable-looking any property sometimes gives you a compiler error. Once you see the tradeoff, those errors become design feedback rather than Swift being Swift.
This post assumes you already use protocols for dependency injection. For the broader “capabilities over inheritance” argument, start with why Swift is protocol-oriented and why that matters. Here, we will stay focused on the part that becomes important in production code: preserving type information all the way to the seam where you actually need to erase it.
1. What associatedtype Is Actually Saying
The standard library’s Sequence is the familiar example. It does not promise that every sequence contains one global element type. It promises that each particular conforming sequence has an element type:
protocol ThumbnailStore {
associatedtype Thumbnail
func thumbnail(for id: String) async throws -> Thumbnail
}
Thumbnail is not a placeholder for Any. It is a promise that, for one ThumbnailStore implementation, the return type is consistent. An image store might return UIImage; a test double might return a small value type; a remote service might return a domain model that a different layer turns into an image.
import UIKit
struct MemoryThumbnailStore: ThumbnailStore {
let values: [String: UIImage]
func thumbnail(for id: String) async throws -> UIImage {
guard let image = values[id] else {
throw CocoaError(.fileNoSuchFile)
}
return image
}
}
struct StubThumbnailStore: ThumbnailStore {
struct Thumbnail: Equatable {
let identifier: String
}
func thumbnail(for id: String) async throws -> Thumbnail {
Thumbnail(identifier: id)
}
}
The protocol does not need to know either concrete type. The conformer supplies it, and the compiler tracks it. That is the whole point: consumers that care about the output can express that requirement without downcasting.
Associated types are not generic types
The two tools are related, but their direction is different. A generic type says the caller chooses Value when it creates Cache<Value>. An associated type says the conforming type establishes its related type when it conforms to Cache.
struct Cache<Value> {
var values: [String: Value] = [:]
}
protocol Persisting {
associatedtype Model
func save(_ model: Model) async throws
}
In real designs, they frequently meet: a generic function consumes a protocol with an associated type. That is where Swift is strongest.
2. Start with a Generic Consumer
Suppose a profile screen owns a loader but needs to stay honest about what it gets back. A generic view model keeps the loader’s image type visible:
import UIKit
@MainActor
final class AvatarViewModel<Store: ThumbnailStore>
where Store.Thumbnail == UIImage {
private let store: Store
private(set) var image: UIImage?
private(set) var errorMessage: String?
init(store: Store) {
self.store = store
}
func loadAvatar(id: String) async {
do {
image = try await store.thumbnail(for: id)
errorMessage = nil
} catch {
image = nil
errorMessage = "Could not load this avatar."
}
}
}
That where clause is worth reading literally: this view model can use any ThumbnailStore, as long as that store’s Thumbnail is UIImage. MemoryThumbnailStore fits. StubThumbnailStore does not, because its output is deliberately different.
This is a better boundary than returning Any and hoping the UI can cast it to UIImage. A wrong implementation fails at compile time, close to the change that caused it. It also makes testing straightforward: make a test store whose associated type is UIImage, return a known image, and assert the visible state. That complements the dependency-boundary advice in MVVM with Clean Architecture in iOS and the async testing patterns in Swift Testing: A Practical Guide for Senior Engineers.
When the generic name is only noise, Swift 5.7’s opaque parameter syntax is a readable equivalent:
func prefetch(_ store: some ThumbnailStore) async {
_ = try? await store.thumbnail(for: "featured")
}
In a parameter, some ThumbnailStore means “an unnamed generic parameter conforming to ThumbnailStore.” The caller chooses the concrete type for each call; Swift keeps that type information while compiling the function. Use an explicit generic parameter when you need to name Store or its associated type in the signature.
3. Why any ThumbnailStore Changes the Conversation
The compiler error developers remember usually starts here:
// This hides which concrete Thumbnail type the store chose.
let store: any ThumbnailStore
any makes an existential: a runtime box that can hold a value of an unknown conforming type. That is useful when the concrete conformer genuinely varies at runtime. It is not the same thing as a generic constraint.
Modern Swift is more capable with associated-type existentials than older blog posts suggest. Declare a primary associated type by putting it in the protocol’s generic-looking declaration:
protocol ThumbnailStore<Thumbnail> {
associatedtype Thumbnail
func thumbnail(for id: String) async throws -> Thumbnail
}
let store: any ThumbnailStore<UIImage> = MemoryThumbnailStore(values: [:])
Now the existential still hides which store it holds, but it retains the one relationship this consumer needs: its thumbnail is a UIImage. This syntax arrived with Swift 5.7, alongside explicit any; it is especially helpful for focused APIs such as Collection<Element>-style constraints.
There is a practical rule here: use a generic or some when behavior is being performed and type relationships matter; use any when you need to store or select among heterogeneous conformers. An existential is an abstraction boundary, not a default spelling for every protocol type.
some and any answer different ownership questions
The shortest useful distinction is this:
some P: one concrete conforming type stays hidden, but remains known consistently to the compiler.any P: a value may hold any conforming type, and its concrete identity is erased at the boundary.
For a function parameter, some P is generic syntax. For a return value, some P lets the implementation choose one concrete return type without exposing it in the API. That is why SwiftUI can return some View: callers do not need the enormous composed view type, but Swift still knows it is one stable type for that declaration.
Do not infer that some means “anything conforming to P” in every position. A function returning some View cannot return Text on one branch and Image on another unless both branches are wrapped in a single underlying type, such as a @ViewBuilder result. The implementation, not the caller, chooses that hidden return type.
4. Type Erasure: A Tool for the Last Mile
Generics are ideal until a type needs to store different conformers behind one non-generic API. Common examples are registries, plugin-style systems, and a composition root that chooses a real service in production and another implementation in previews.
Type erasure turns that varying implementation into one concrete wrapper. A small, focused wrapper for our store looks like this:
struct AnyThumbnailStore<Thumbnail>: ThumbnailStore {
private let load: (String) async throws -> Thumbnail
init<Store: ThumbnailStore>(_ store: Store)
where Store.Thumbnail == Thumbnail {
load = { id in
try await store.thumbnail(for: id)
}
}
func thumbnail(for id: String) async throws -> Thumbnail {
try await load(id)
}
}
The generic parameter on AnyThumbnailStore is intentional. We are erasing the implementation type, not the result type. That preserves the contract a caller needs:
let productionStore = AnyThumbnailStore(MemoryThumbnailStore(values: [:]))
let model = AvatarViewModel(store: productionStore)
In an app, put the erased wrapper at the composition boundary, not at the bottom of every protocol declaration. If every internal API accepts AnyThumbnailStore, you have recreated dynamic typing with extra boilerplate. Let concrete and generic code flow within a feature; erase when the architecture needs a stable storage type.
Also be precise about concurrency. The wrapper above is not automatically Sendable; neither is every ThumbnailStore. If it crosses actor boundaries, model that requirement explicitly—perhaps protocol ThumbnailStore: Sendable and associatedtype Thumbnail: Sendable—and make sure the captured conformer is genuinely safe. Swift 6’s stricter checking is valuable here; struct versus class in Swift 6 explains why a Sendable label is a safety contract, not a performance decoration.
5. The Compiler Errors That Point to the Design
The annoying part of protocols with associated types is rarely the declaration. It is using one after erasing too much information.
“Member cannot be used on value of type any …”
This often means the member mentions an associated type whose concrete identity matters to the result. Before reaching for a cast, ask which fact the caller needs. If it needs a UIImage, constrain the existential to any ThumbnailStore<UIImage> or make the consumer generic. If it truly does not need the result’s static type, an existential may be appropriate.
“Type does not conform to protocol”
Check the associated type inferred from your implementation. A protocol method returning [Article] does not satisfy a requirement returning [ArticleDTO], even if both have similarly named fields. That mismatch is useful: map at a layer boundary rather than letting a data-transfer type leak into the UI.
Accidentally choosing two unrelated generic types
This looks harmless but does not say what many people intend:
func compare<S1: Sequence, S2: Sequence>(_ lhs: S1, _ rhs: S2) {
// S1.Element and S2.Element may be completely different.
}
When the elements must match, make the relationship explicit:
func append<S1: Sequence, S2: Sequence>(
_ lhs: S1,
_ rhs: S2
) -> [S1.Element] where S1.Element == S2.Element {
Array(lhs) + Array(rhs)
}
This is the same mental model as Store.Thumbnail == UIImage: associated-type constraints are how you tell the compiler which values belong together.
Expecting extension-only methods to be dynamically dispatched
If a method exists only in a protocol extension—not in the protocol requirement list—calling it through a protocol or generic boundary uses the extension implementation. A conforming type’s same-named method is not an override. Put behavior that must vary by conformer in the protocol requirement list, then provide a default implementation in an extension if that is useful.
6. Performance and Version Notes That Matter
Associated types do not add runtime overhead by themselves. They participate in the static type system, and generic code can often be specialized for a concrete conformer. That is why a generic AvatarViewModel<Store> is both expressive and a reasonable performance default.
Existentials (any) and type-erased wrappers may introduce a box and dynamic dispatch. Usually that cost is irrelevant next to disk, networking, image decoding, or a SwiftUI render pass. It can matter in hot loops over small values or parsing-heavy code. Measure before building a generic maze to avoid one indirection.
Version context is important because the advice changed. associatedtype has been foundational Swift since 1.0. Swift 5.7 added the explicit any spelling, primary associated types, and some in parameter position. Current toolchains make many constrained existentials practical, but old code and old articles may still assume that a protocol with an associated type can never be used existentially. Check your project’s Swift language mode and deployment/toolchain policy before adopting the newest signature syntax in a package API.
For durable libraries, this also affects API design. Exposing a generic requirement preserves static relationships but can make type inference and error messages heavier for callers. Exposing any simplifies storage and late binding but intentionally gives up some information. Neither is universally “more Swifty.” Choose the boundary you actually need.
Conclusion
associatedtype is Swift’s way of keeping an abstraction honest about the data it handles. A repository is not merely “a repository”; it is a repository of a particular entity. A loader is not merely “a loader”; it produces a particular asset. Let that relationship survive in your types as long as it helps the compiler help you.
Start generic. Use some when it makes a one-off generic parameter easier to read. Use constrained any when an abstraction must store different conformers but still needs one key relationship. Reach for type erasure only at a real architecture boundary. The result is less casting, more useful compiler errors, and APIs that say what they mean.
Key Takeaways
associatedtypeexpresses a per-conformer relationship such as a store’s model or a sequence’s element.- Generic consumers preserve that relationship and should be the default when behavior and output types matter.
somein a parameter is shorthand for a generic parameter;anyis an existential storage boundary.- Primary associated types enable focused constrained existentials such as
any ThumbnailStore<UIImage>on modern Swift toolchains. - Type erasure should hide a concrete implementation at a boundary while retaining the associated type callers still need.
- Associated types are compile-time structure; profile before treating existential dispatch as a bottleneck.