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.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

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:

  1. Defines a method, fetch(completion:), which receives a completion handler that’s called with the result of the fetch — in this case, an array of DataType.
  2. 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 named DataType, 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?

Note: It will take a few changes to get the project compiling again. Don’t panic if it doesn’t compile after every code change! :]

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:

  1. First, this code updates the definition of the Compose struct to be generic over two types, T and U. Both of these must conform to MIFilter.
  2. Then, it updates the private properties first and second to have the generic types provided rather than the less specific type MIFilter.
  3. 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:

Compiling the MagicImage Library

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.

App running with Equatable MIFilters