Opaque Return Types and Type Erasure
Learn how to use opaque return types and type erasure to improve your understanding of intuitive and effective APIs, both as a developer and a consumer. By Tom Elliott.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Opaque Return Types and Type Erasure
35 mins
- Getting Started
- Sharing Secrets
- Interesting Images
- Protocols Primer
- Fun With Filters
- Getting Generics
- Associated Types
- Paltry Protocols
- Generics to the Rescue?
- Tape and String — Apply a Temporary Fix
- Terrible Types
- Taming Types
- Hiding Filters
- Enter Type Erasure
- Finishing Filtergram
- Where to Go From Here?
Getting Generics
As a refresher, generics allow a developer to write a property or a function that defers specifying the specific type(s) in use until called by another piece of code.
A common example is the Array structure (or any of the other collection types in the Swift standard library). Arrays can hold any type of data. But for any single Array, every element in the array must have the same type. Array is defined as:
@frozen struct Array<Element>
Here, Element
is a named generic type. The authors of the Swift standard library cannot know what type is being stored in the Array. That’s up to the developer who’s using it. Instead, they use Element
as a placeholder.
In other words, Liam the Library Author doesn’t know the secret of the type, but Abbie and Corinne do.
- 🤓 Liam the Library Author ❌
- 🤖 Corinne the Compiler ✅
- 🦸♀️Abbie the App Author ✅
Associated Types
Protocols can also become generic using a feature called associated types. This allows a protocol to provide a name for a type that isn’t defined until the protocol is adopted.
For example, consider a protocol concerned with fetching data:
protocol DataFetcher {
// 1
func fetch(completion: [DataType]? -> Void)
// 2
associatedtype DataType
}
This protocol:
- Defines a method,
fetch(completion:)
, which receives a completion handler that’s called with the result of the fetch — in this case, an array ofDataType
. - Defines an associated type,
DataType
. The library author (Liam) doesn’t want to limit what type of data this protocol fetches. It should be generic over any type of data. So instead, the protocol defines an associated type namedDataType
, which can be filled in later.
Later, when Abbie wants to create a structure to fetch JSON data, she could write the following code:
struct JSONDataFetcher: DataFetcher {
func fetch(completion: [JSONData]? -> Void) {
// ... fetch JSON data from your API
}
}
As with generic types and functions, Abbie has to define the concrete type, in this case JSONData
, but Liam doesn’t know or care what it is when he defines the protocol.
Paltry Protocols
Your app will want to apply a filter every time selectedFilter
updates. To do this, the compiler needs to tell if two instances of MIFilter
are equal. This is done by conforming to the Equatable
protocol.
Open MIFilter.swift. Near the top of the file, update the definition for the MIFilter
protocol to include both Equatable
and Identifiable
:
public protocol MIFilter: Equatable, Identifiable {
⚠️ If you build and run the app now, compilation fails with the following error:
Protocol 'MIFilter' can only be used as a generic constraint because it has Self or associated type requirements
What’s going on here? From the section above, you know what associated types are. And the declarations for Equatable
and Identifiable
don’t include any, so it can’t be that.
If you read the requirements of the Equatable
protocol, you find the following required type method:
static func == (lhs: Self, rhs: Self) -> Bool
Self
in the above method signature refers to the type of the actual instances being compared. It’s a form of generics as the author of the Swift standard library doesn’t know what type will be being used when ==
is called. But they’re able to state the lhs
and rhs
parameters have to be the same type.
As discussed above, the compiler doesn’t know of the underlying types when a protocol is used in place of a concrete type. But the compiler knows of the underlying type when a generic type is used.
Consequently, given your MIFilter
protocol now has a Self requirement, it can only be used as a generic constraint. Fortunately, the above section contained a refresher on generics. It’s time to put that refresher to good use.
Generics to the Rescue?
Still in MIFilter.swift, replace the implementation of Compose
with the following:
// 1
public struct Compose<T: MIFilter, U: MIFilter>: MIFilter {
public var name: String {
return "Compose<\(type(of: first)), \(type(of: second))>"
}
public var id: String { self.name }
// 2
let first: T
let second: U
// 3
public init(first: T, second: U) {
self.first = first
self.second = second
}
public func apply(to miImage: MIImage) -> MIImage {
return second.apply(to: first.apply(to: miImage))
}
}
Here’s what’s going on:
- First, this code updates the definition of the
Compose
struct to be generic over two types,T
andU
. Both of these must conform toMIFilter
. - Then, it updates the private properties
first
andsecond
to have the generic types provided rather than the less specific typeMIFilter
. - Finally, the code updates the initializer to accept parameters of the two generic types rather than any
MIFilter
.
Next, open MagicImage+UIImage.swift. Update the declaration of apply(_:)
like so:
func apply<T: MIFilter>(_ filter: T) -> UIImage {
Like the previous change, you update apply(_:)
to be generic, stating it accepts a filter with a concrete type T
that conforms to MIFilter
rather than any filter conforming to MIFilter
.
Now change the scheme to MagicImage from MagicImageDemo:
Build with Command-B. This will now build successfully. :]
Tape and String — Apply a Temporary Fix
Change the scheme back to MagicImageDemo.
The demo project won’t compile yet because it’s still trying to use the MIImage
protocol as a type constraint without generics. You’ll fix this properly later, but for now the easiest way to get the app compiling is to update any type references from MIFilter
to IdentityFilter
.
Open FiltergramView.swift. Remove the typecast from the declaration of the selectedFilter
state:
@State private var selectedFilter = IdentityFilter()
And underneath, update the type of the filters
property:
let filters: [IdentityFilter]
Next, open FilterBarView.swift. Update the declaration of the selectedFilter
binding to be typed as IdentityFilter
as well:
@Binding var selectedFilter: IdentityFilter
And similarly with the type of the allFilters
array:
let allFilters: [IdentityFilter]
Next, open FilterButton.swift and perform the same steps. Start with the type of the selectedFilter
binding:
@Binding var selectedFilter: IdentityFilter
Then, update the filter
property:
let filter: IdentityFilter
And now a drum roll for the bit you’ve been waiting for…
Open FiltergramView.swift again and add the following view modifier to the end of the body
property:
.onChange(of: selectedFilter) { _ in loadImage() }
All the above changes were made in service of this one line! Now that MIFilter
is Equatable
, SwiftUI can compare two filters and call loadImage
when the selectedFilter
state changes.
Build and run the demo project. It will now compile correctly again.