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 4 of 4 of this article. Click here to view the first page.

Enter Type Erasure

Earlier, you changed the Filtergram app so the list of filters was an array of type [IdentityFilter]. This won’t let you provide different filters for the user to choose from. It’s time to fix that! :]

The problem is that the compiler requires you to specify the exact type of MIFilter in the filters array because MIFilter has a self-type requirement. But you want to store filters with different types in an array. To work around this, you’ll use a technique known as type erasure.

You likely have used type erasure before without realizing it. Apple uses it frequently in the Swift standard library. Examples include AnyView, which is a type erased View in SwiftUI, and AnyCancellable, which is a type erased Cancellable in Combine.

Unlike opaque return types, which are a feature of Swift, type erasure is a catch-all term for several techniques you can apply in any strongly typed language. Here, you’ll use a technique known as Boxing to type erase MIFilter.

The general idea is to create a concrete wrapper type (a box) that wraps either an instance of the wrapped type or any properties and methods of that type. Anytime a method is called on the wrapper type, it proxies the call to the wrapped type. Time to give it a go!

Create a new Swift file in the MagicImage Xcode project called AnyFilter.swift. Add the following code:

// 1
public struct AnyFilter: MIFilter {
  // 2
  public static func == (lhs: AnyFilter, rhs: AnyFilter) -> Bool {
    lhs.id == rhs.id
  }

  // 3
  public let name: String
  public var id: String { "AnyFilter(\(self.name))" }

  // 4
  private let wrappedApply: (MIImage) -> MIImage

  // 5
  public init<T: MIFilter>(_ filter: T) {
    name = filter.name
    wrappedApply = filter.apply(to:)
  }

  // 6
  public func apply(to miImage: MIImage) -> MIImage {
    return wrappedApply(miImage)
  }
}

In this code, you:

  1. Define a new structure called AnyFilter, which conforms to MIFilter.
  2. Implement ==, required for conformance to Equatable.
  3. Define name and id properties, as required by the MIFilter and Identifiable protocols, respectively.
  4. Define a property called wrappedApply, which is typed as a function receiving an MIImage and returning an MIImage. This is the same definition as the apply(to:) method defined in the MIFilter protocol.
  5. Create the default initializer for AnyFilter. You wrap the filter provided to the initializer by storing references to its name and apply(to:) method in the properties of AnyFilter.
  6. Finally, when the apply(to:) method of AnyFilter is called, you proxy the call to the apply(to:) method of the wrapped filter.

Now, anywhere in your code you want to erase the type of an MIFilter, you can simply wrap it in an AnyFilter, like so:

Before:

// Type is Posterize
let posterize = Posterize()

After:

// Type is AnyFilter
let posterize = AnyFilter(Posterize())

You might find it annoying to have to keep wrapping filters with AnyFilter(). In that case, a simple trick is to define a method asAnyFilter() in an extension of MIFilter. Add the following to AnyFilter.swift at the end of the file:

public extension MIFilter {
  func asAnyFilter() -> AnyFilter {
    return AnyFilter(self)
  }
}

When using AnyFilter in her app, Abbie prevents Liam and Corinne from seeing the underlying type of the wrapped filter. In the game of “Who knows the secret?”, type erasure is a bit like “reverse protocols”:

  • 🤓 Liam the library author ❌
  • 🤖 Corinne the Compiler ❌
  • 🦸‍♀️Abbie the App Author ✅
Note: SwiftUI provides a type-erased view via the AnyView structure. However, you should limit your use of AnyView as much as possible because SwiftUI’s view hierarchy diffing algorithm is significantly less efficient when dealing with AnyView. Using AnyView too often will create performance problems for your app.

Finishing Filtergram

It’s time to put AnyFilter to good use.

⚠️ Like when you updated all the types to [IdentityFilter] earlier, you’ll need to make several changes before the demo app will compile. Don’t panic!

Open FiltergramView.swift. Update the definition of the selected filter state:

@State private var selectedFilter = IdentityFilter().asAnyFilter()

Along with the type of the filters array:

let filters: [AnyFilter]

And replace the contents of the initializer with the following:

let identity = IdentityFilter()
let sepia = Sepia()
let olde = Olde()
let posterize = Posterize()
let crystallize = Crystallize()
let flipHorizontally = HorizontalFlip()

filters = [
  identity.asAnyFilter(),
  sepia.asAnyFilter(),
  olde.asAnyFilter(),
  AnyFilter(posterize),
  AnyFilter(crystallize),
  AnyFilter(flipHorizontally)
]

Here, you define some filters and add them to the filters array. Note how it doesn’t matter if you use .asAnyFilter() or the AnyFilter initializer. Feel free to use whichever you prefer.

Next, open FilterBarView.swift and update the type for the selectedFilter binding:

@Binding var selectedFilter: AnyFilter

Next, update the type for the allFilters array.:

let allFilters: [AnyFilter]

And the same for the selectedFilter and filters values in the preview:

let selectedFilter = IdentityFilter().asAnyFilter()
let filters: [AnyFilter] = [
  IdentityFilter().asAnyFilter()
]

Next, open FilterButton.swift and update the type of the SelectedFilter binding:

@Binding var selectedFilter: AnyFilter

As well as the Filter property:

let filter: AnyFilter

In the body property, update the definition of isSelectedFilter:

let isSelectedFilter = selectedFilter == AnyFilter(filter)

Here, you use the Identifiable protocol to update the look of the button when the filter it represents is selected.

Also, update FilterButton in the preview to type erase the selected filter and filter to AnyFilter:

FilterButton(
  selectedFilter: .constant(IdentityFilter().asAnyFilter()),
  filter: IdentityFilter().asAnyFilter())

Build and run. The app now compiles without any errors.

The final app with multiple filters

The app now has a list of filter buttons along the bottom of the screen. You were able to store filters of different types in the same array by erasing the type of each filter.

Woo-hoo! Tap the buttons to change the filter applied to the butterfly image.

Where to Go From Here?

You can download the completed project files by clicking the Download Materials button at the top or bottom of this tutorial.

You’ve successfully used opaque return types in the Magic Image library and seen how you can apply type erasure in the Filtergram app to hide concrete types from the compiler.

Along with generics and protocols, opaque return types and type erasure allow you to hide the “secret” type information from different parties. As a reminder:

Both opaque return types and type erasure are advanced topics you might not use regularly. But they’re also both used extensively in common Swift frameworks like SwiftUI and Combine, so understanding what they are and why they’re necessary is valuable for every budding Swift developer.

You can learn more about opaque return types in the Swift documentation. Although you might not define functions that return opaque types often, you’ll use them every time you create a view with SwiftUI.

Apple doesn’t cover type erasure specifically anywhere in its official documents because it’s not a feature of the language per se. But it’s a technique that’s used heavily in the standard library. When using Combine, you’ll come across AnyCancellable and AnyPublisher frequently in the APIs. SwiftUI provides AnyView to allow you to type erase views.

We hope you enjoyed this tutorial, and if you have any questions or comments, please join the forum discussion below!