Opaque and Boxed Protocol Types
Swift offers two mechanisms to conceal a value's underlying type: opaque types and boxed protocol types. These are especially handy at module boundaries, where you want to keep the return type private while exposing only the essential interface.
- Opaque types hide the exact type from callers but let the compiler track it for optimizations. The function specifies what protocols the return value conforms to, preserving type identity.
- Boxed protocol types (existentials) store any conforming type, but lose type identity—it's resolved at runtime and can vary.
Both approaches let you build flexible APIs without leaking internal implementation details.
The Challenge Opaque Types Address
Imagine a library for generating simple text patterns, like number pyramids or rectangles. Define a Drawable protocol for the core draw() method:
protocol Drawable {
func draw() -> String
}
struct Pyramid: Drawable {
var height: Int
func draw() -> String {
var result: [String] = []
for row in 1...height {
result.append(String(repeating: " ", count: height - row) + String(1...row, joinedBy: ""))
}
return result.joined(separator: "\n")
}
}
let smallPyramid = Pyramid(height: 3)
print(smallPyramid.draw())
// 1
// 12
//123To flip a pattern vertically, you might use generics:
struct FlippedPattern<T: Drawable>: Drawable {
var pattern: T
func draw() -> String {
let lines = pattern.draw().split(separator: "\n")
return lines.reversed().joined(separator: "\n")
}
}
let flippedPyramid = FlippedPattern(pattern: smallPyramid)
print(flippedPyramid.draw())
//123
// 12
// 1Similarly, for joining two patterns:
struct JoinedPattern<T: Drawable, U: Drawable>: Drawable {
var top: T
var bottom: U
func draw() -> String {
return top.draw() + "\n" + bottom.draw()
}
}
let joinedPyramids = JoinedPattern(top: smallPyramid, bottom: flippedPyramid)
print(joinedPyramids.draw())
// 1
// 12
//123
//123
// 12
// 1The issue? These generics expose internal types like FlippedPattern<Pyramid> or JoinedPattern<Pyramid, FlippedPattern<Pyramid>>. Users see your wrapper details, cluttering the API and risking leaks of private types. Ideally, operations like flip and join should just return a Drawable—hiding the how.
Using Opaque Return Types
Opaque types flip the script on generics: the function chooses the concrete type, abstracted via protocols. Callers get a some Protocol return, knowing only it conforms, not what it is.
Add a Rectangle for variety:
struct Rectangle: Drawable {
var width: Int
var height: Int
func draw() -> String {
let line = String(repeating: "-", count: width)
let result = Array(repeating: line, count: height)
return result.joined(separator: "\n")
}
}Now, build a "trapezoid" without exposing internals:
func makeTrapezoid() -> some Drawable {
let top = Pyramid(height: 2)
let middle = Rectangle(width: 3, height: 2)
let bottom = FlippedPattern(pattern: top)
let trapezoid = JoinedPattern(
top: top,
bottom: JoinedPattern(top: middle, bottom: bottom)
)
return trapezoid
}
let trapezoid = makeTrapezoid()
print(trapezoid.draw())
// 1
//12
//---
//---
//12
// 1Here, some Drawable guarantees a drawable value but hides the combo of pyramids and rectangles. You could refactor internals without API changes.
Combine with generics for reusable ops:
func flip<T: Drawable>(_ pattern: T) -> some Drawable {
return FlippedPattern(pattern: pattern)
}
func join<T: Drawable, U: Drawable>(_ top: T, _ bottom: U) -> some Drawable {
return JoinedPattern(top: top, bottom: bottom)
}
let opaqueJoined = join(smallPyramid, flip(smallPyramid))
print(opaqueJoined.draw())
// 1
// 12
//123
//123
// 12
// 1All returns must match one underlying type (e.g., can't mix FlippedPattern and plain types without wrappers). But generics can parameterize it, like returning [T] for repeats:
func repeatPattern<T: Drawable>(_ pattern: T, times: Int) -> some Collection<Drawable> {
return Array(repeating: pattern, count: times)
}Boxed Protocol Types (Existentials)
Prefix protocols with any for boxes: any Drawable holds any conforming type, enabling heterogeneous collections at runtime cost (indirection).
Example: Stack patterns vertically:
struct StackedPatterns: Drawable {
var patterns: [any Drawable]
func draw() -> String {
return patterns.map { $0.draw() }.joined(separator: "\n\n")
}
}
let tallPyramid = Pyramid(height: 4)
let wideRect = Rectangle(width: 4, height: 2)
let stack = StackedPatterns(patterns: [tallPyramid, wideRect])
print(stack.draw())
// 1
// 12
// 123
//1234
//
//----
//----[any Drawable] mixes types; access only protocol methods (e.g., draw(), not height). Downcast if needed: if let pyr = stack.patterns[0] as? Pyramid { print(pyr.height) }.
Compare options for patterns:
- Generics (
[S]whereS: Drawable): Fixed type, visible. - Opaque (
[some Drawable]): Fixed hidden type. - Boxed (
[any Drawable]): Mixed hidden types—most flexible.
Key Differences
Both mimic protocol returns, but:
- Opaque (
some): One fixed type (hidden); preserves identity for optimizations, equality, nesting. - Boxed (
any): Any conforming type; flexible but loses identity—no equality, harder nesting.
Boxed example (looser contract):
func looseFlip<T: Drawable>(_ pattern: T) -> any Drawable {
if pattern is Rectangle {
return pattern // Could return plain or wrapped
}
return FlippedPattern(pattern: pattern)
}This might return different types per call, blocking ops like == or chaining: looseFlip(looseFlip(smallPyramid)) fails (existential ≠ protocol-conforming).
For protocols with associated types (e.g., Container<Item>), only some works:
protocol Container {
associatedtype Item
var count: Int { get }
subscript(i: Int) -> Item { get }
}
extension Array: Container {}
func makeContainer<T>(_ item: T) -> some Container {
return [item]
}
let container = makeContainer(42)
print(container[0]) // Infers IntOpaque Parameter Types
some in params is shorthand for unnamed generics—no true opacity:
func drawTwice<Pattern: Drawable>(_ pattern: Pattern) -> String {
let drawn = pattern.draw()
return drawn + "\n" + drawn
}
// Equivalent to:
func drawTwiceShort(_ pattern: some Drawable) -> String {
let drawn = pattern.draw()
return drawn + "\n" + drawn
}Multi-params allow different types: func merge(_ top: some Drawable, _ bottom: some Drawable) -> String { ... }. Limits: No where clauses or == constraints.