Make your SwiftUI component generic over the protocol’s conforming type, then declare its stored view as Provider.Content. This preserves the relationship between the provider and its associated view type throughout the component.
This tutorial builds a small macOS window containing two synthetic tiles. The task is to store a provider and the view it produces without replacing the associated type with any View or AnyView. The same pattern applies when a reusable desktop component accepts a custom label, accessory, or content provider.
What relationship must the stored properties preserve?
An associated type names a type chosen by each protocol conformance. If a protocol declares associatedtype Content: View, a conforming provider must supply one particular content type. Generic code can refer to that type as Provider.Content, even when its implementation is hidden behind an opaque result. Swift documents associated types and generic constraints in its Generics chapter.
The component therefore needs two connected declarations: a generic parameter constrained to the provider protocol, and a stored content property whose type is that parameter’s associated type. Declaring two independent properties that merely conform to protocols does not express this connection.
SwiftUI uses a related contract itself: View declares associatedtype Body: View, and Swift infers that associated type from a custom view’s body implementation. Your provider’s Content and the component’s Body are separate associated types; they do not need to be equal. See Apple’s Body documentation.
Build a complete macOS example
Use an Xcode macOS app project with SwiftUI and a macOS 13 or later deployment target for this example. Replace the generated app and content-view declarations with the following single file, keeping exactly one @main declaration in the target. Apple documents this app-entry structure and the use of WindowGroup in App.
The example deliberately uses the longstanding associated-type and named-generic syntax. Opaque results were implemented in Swift 5.1; the optional primary-associated-type syntax discussed later requires Swift 5.7. The complete file is intended for a current Swift 6 toolchain. It was inspected against the retrieved documentation but was not compiled here because this environment has no Swift compiler. The chosen macOS deployment target is an example prerequisite, not a claim about the earliest OS supporting every underlying feature. See SE-0244 and SE-0346.
import SwiftUI
@MainActor
protocol TileProvider {
associatedtype Content: View
var title: String { get }
func makeContent() -> Content
}
@MainActor
struct TextTile: TileProvider {
let title: String
let message: String
func makeContent() -> Text {
Text(message)
}
}
@MainActor
struct CircleTile: TileProvider {
let title: String
let caption: String
func makeContent() -> some View {
HStack(spacing: 8) {
Circle()
.fill(Color.blue)
.frame(width: 16, height: 16)
Text(caption)
}
}
}
@MainActor
struct TileCard<Provider: TileProvider>: View {
private let provider: Provider
private let content: Provider.Content
init(provider: Provider) {
self.provider = provider
self.content = provider.makeContent()
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(provider.title)
.font(.headline)
content
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray.opacity(0.12))
}
}
@MainActor
struct ContentView: View {
var body: some View {
VStack(spacing: 12) {
TileCard(provider: TextTile(
title: "Text tile",
message: "Blue square"
))
TileCard(provider: CircleTile(
title: "Shape tile",
caption: "Blue circle"
))
}
.padding()
.frame(width: 360)
}
}
@main
@MainActor
struct AssociatedViewDemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Build and run the macOS target. The intended result is a window containing a text tile and a tile with a blue circle beside its caption. Both use the same generic component, with the provider type inferred independently at each call site.
The explicit @MainActor annotations keep these UI-producing declarations in the same actor context. Apple’s current View documentation declares the protocol with main-actor isolation. The annotations here also make the custom provider protocol’s intended context explicit.
Why does the generic component preserve the type?
For the first card, Provider is TextTile, so Provider.Content is Text. For the second card, Provider is CircleTile, and its associated content type is the opaque result of CircleTile.makeContent(). You can name that type through CircleTile.Content without spelling out the nested SwiftUI layout type.
The essential assignment is self.content = provider.makeContent(). The protocol guarantees that this method returns exactly Provider.Content, which is exactly the stored property’s declared type. This is a compile-time relationship derived from the generic constraint, rather than a runtime type comparison. Swift’s Generics chapter explains accessing associated types through constrained generic parameters.
Using some View in the conforming implementation does not erase this relationship. An opaque result hides the concrete implementation while preserving its type identity. That makes it suitable as an associated-type witness. Swift explains the distinction in Opaque and Boxed Protocol Types.
These cards have different specializations: TileCard<TextTile> and TileCard<CircleTile>. Reusing the source declaration does not require every card to have one universal stored-content type.
Why don’t independent opaque or existential properties solve it?
The following are diagnostic fragments, not replacements for the complete program:
// Diagnostic fragment: no initializer establishes the opaque type.
let content: some View
// Diagnostic fragment: hides which conforming provider is stored.
let provider: any TileProvider
// Diagnostic fragment: stores an existential view value.
let content: any View
An opaque stored property needs a declaration initializer that establishes its underlying type. Writing some View does not create a caller-selected generic parameter that an arbitrary component initializer can fill later. For example, let content: some View = Text("Sample") establishes a type for that declaration; it does not connect the property to another property’s associated type. This stored-property rule is documented in SE-0244: Opaque Result Types.
An existential such as any TileProvider permits storage of different conforming provider types. It hides the particular conforming type, so the declaration no longer exposes the relationship as Provider.Content. Similarly, any View is an existential container; it is not the same contract as a named generic type conforming to View. See Swift’s explanation of boxed protocol types.
Swift can implicitly open existential arguments for some generic calls. That does not make every existential usable in every generic initializer. In particular, SE-0352 restricts opening when the unknown underlying type would escape inside a generic result such as X<T>. A generic component specialization can encounter precisely that boundary. Keep the provider generic before constructing the card instead of assuming an existential can always be converted back into a persistent specialization. See SE-0352: Implicitly Opened Existentials.
Choose storage according to when content should be produced
The example calls makeContent() during each TileCard initialization and stores the returned view value. It does not establish a persistent cache across later component initializations. Keeping both properties demonstrates the requested type relationship, but a component that needs only the resulting view could omit the stored provider.
If the component should request content whenever its body is evaluated, retain only the provider. The following is a replacement component declaration to use with the protocol and app above:
@MainActor
struct TileCard<Provider: TileProvider>: View {
private let provider: Provider
init(provider: Provider) {
self.provider = provider
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(provider.title)
.font(.headline)
provider.makeContent()
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.gray.opacity(0.12))
}
}
Both designs preserve the associated type. Their difference follows directly from where the method call appears: initialization in the first design, body evaluation in the second. Choose that timing deliberately, and keep content production free of work that depends on being called exactly once.
Use this decision aid when adapting the example:
| Component requirement | Declaration strategy | Type relationship |
|---|---|---|
| Store the provider and its produced view | Generic provider plus Provider.Content |
Preserves both provider identity and its content type |
| Produce content during body evaluation | Store the generic provider | Preserves the associated type without storing a separate content value |
| Store providers with a known common content type | Consider a constrained existential | Preserves the specified content equality while hiding provider identity |
| Deliberately erase differing view implementations | Use AnyView at a chosen boundary |
Stores the wrapper type instead of exposing the original view type |
Do primary associated types remove the need for generics?
Primary associated types offer a shorter way to express selected same-type constraints. If you own the protocol, this alternative header makes Content primary; the remaining protocol requirements stay the same:
// Alternative protocol header, requiring Swift 5.7 or later.
@MainActor
protocol TileProvider<Content> {
associatedtype Content: View
var title: String { get }
func makeContent() -> Content
}
With that declaration, any TileProvider<Text> describes an existential whose associated content type is known to be Text. Different provider implementations can share that storage type only if their conformances expose that exact content equality. The syntax constrains an associated type; it does not turn the protocol into a generic struct. See SE-0346 and SE-0353.
This does not combine arbitrary unknown view types into one preserved concrete type. In particular, CircleTile exposes an opaque content result, so callers cannot simply treat that result as Text. Use the original generic component when the provider’s associated content should remain unrestricted.
Constrained existentials were implemented in Swift 5.7, and SE-0353 explicitly identifies runtime and backward-deployment limits. Compiler support alone is therefore insufficient to establish deployment compatibility for that optional design. Check the target toolchain’s availability diagnostics before adopting it. The main example does not depend on constrained existential storage. See SE-0353’s ABI discussion.
What should you check when compilation fails?
Trace the type from the protocol requirement to the stored property before changing the layout:
- Confirm the protocol declares an associated type constrained to
View. - Confirm the component has a named generic provider parameter constrained to that protocol.
- Declare stored content as
Provider.Content, matching the method’s return type. - Check whether a parent already converted the provider to
any TileProvider. - If adding a modifier before storage, check its result type: the modified view may no longer be
Provider.Content. - Check actor-isolation diagnostics separately from associated-type diagnostics.
Apple documents that view modifiers wrap a view in another view. Consequently, assigning provider.makeContent().padding() into a property declared as Provider.Content changes the contract the assignment must satisfy. Store the original content and apply presentation modifiers in body, or redesign the stored type to describe the modified result. See View.
Builders also have a version boundary. Apple’s current documentation describes ViewBuilder behavior for Xcode 26 and earlier, then different content-building behavior beginning with Xcode 27. The example avoids a custom builder requirement: each provider returns one explicit expression. If you extend it with builder-driven provider APIs, follow the documentation for the Xcode version you actually build with. See ViewBuilder.
If you deliberately choose AnyView, account for its documented behavior: changing the wrapped view type destroys the old hierarchy and creates a new one. That is a separate design decision from preserving an associated type in storage. Apple documents this in AnyView.
For this component, keep the relationship visible at its storage boundary: a generic Provider, a result of Provider.Content, and a stored property of that same type. You can then change a provider’s internal layout while letting the compiler carry its associated view type through the component.
Sources
- Generics — Swift.org
- Opaque and Boxed Protocol Types — Swift.org
- View — Apple
- Body — Apple
- App — Apple
- ViewBuilder — Apple
- AnyView — Apple
- SE-0244: Opaque Result Types — Swift Evolution
- SE-0346: Lightweight same-type requirements for primary associated types — Swift Evolution
- SE-0352: Implicitly Opened Existentials — Swift Evolution
- SE-0353: Constrained Existential Types — Swift Evolution