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

Terrible Types

Before you get too carried away, there are two problems with the generics-based approach you’ve built:

  1. The public API of your MagicImage library gives away too much internal detail. This makes it harder for you (or Liam) to change the internals of the library later without breaking code written by the users of your library.
  2. Although the demo app now compiles, it isn’t very useful. Why? Because the filters all need to have the same type! This won’t make for an interesting app at all.

Types are a pain

Before tackling the second point, you should explore why the first is so problematic.

Open FiltergramView.swift. Find the code in loadImage() that sets let uiImage and replace it with the following:

let oldeMirror = Compose(first: Olde(), second: HorizontalFlip())
let uiImage = inputImage.apply(oldeMirror)

OldeMirror is a filter created by combining the Olde and HorizontalFlip filters.

Option-click oldeMirror to open the inferred-type dialog.

Inferring the type of Olde Mirror

Notice how the type of oldeMirror is Compose<Olde, HorizontalFlip>. At first glance, this might seem just a little annoying. It’s a bit of a mouthful and isn’t a concise type for Abbie and other users of the MagicImage library to consume. But it gets worse.

Imagine Abbie really likes the Olde Mirror filter and wants to compose this filter with other filters as easily as possible. She might be tempted to write a function like so:

func composeWithOldeMirror(
  _ oldeMirror: MIFilter, 
  with newFilter: MIFilter
) -> MIFilter {
    return Compose(first: oldeMirror, second: newFilter)
}

But this won’t work. Why? Because it throws the same error you saw above:

Protocol 'MIFilter' can only be used as a generic constraint because it has Self or associated type requirements

To get this to work, Abbie would have to modify the function declaration to include the actual types, which looks a bit like this:

func composeWithOldeMirror<T: MIFilter>
(
  _ oldeMirror: Compose<Olde, HorizontalFlip>, 
  with newFilter: T
) -> Compose<Compose<Olde, HorizontalFlip>, T> {
    return Compose(first: oldeMirror, second: newFilter)
  }

It’s doubtful anyone would claim this code was easy to read. But the problem runs deeper than mere aesthetics.

Imagine Abbie is now running some performance analysis of MagicImage. She notices the Olde Mirror filter performs twice as fast if the Olde filter is applied after the flipping filter. So she changes the implementation of oldeMirror from:

let oldeMirror = Compose(first: Olde(), second: HorizontalFlip())

To:

let oldeMirror = Compose(first: HorizontalFlip(), second: Olde())

Abbie’s composeWithOldeMirror function will now fail to compile. Instead, she’ll see the following error:

Cannot convert value of type 'Compose<Olde, HorizontalFlip>' to expected argument type 'Compose<HorizontalFlip, Olde>'.

The problem is the Magic Image API forces users to care about internal types they don’t even need to know exist. The Compose type isn’t a part of the Magic Image library’s public interface but has leaked out regardless. As a library author, you’ve lost the flexibility that came with using protocols. Or have you?

Taming Types

Since version 5.1, Swift has supported a concept called opaque return types. A function (or method) with an opaque return type keeps the type secret from callers of the function. Instead, the function describes the return value in terms of the protocols it supports. So how is this different from just using a protocol?

It all comes down to what Corinne the Compiler sees. With protocols, the type secret is hidden from Abbie as well as Corinne. But when using opaque return types, the compiler gets access to the type secret as well.

  • 🤓 Liam the Library Author ✅
  • 🤖 Corinne the Compiler ✅
  • 🦸‍♀️Abbie the App Author ❌

You can think of opaque return types as “reverse generics”. Both features allow the compiler to know the type secret. But unlike generics (where Liam doesn’t know the secret), with opaque return types, it is Abbie who doesn’t know the secret.

In Swift, a function is declared as returning an opaque return type using the some keyword. For example, like this:

func selectedFilter() -> some MIFilter { ... }

An important distinction with functions returning an opaque type is that all branches in the function must return the same type.

For example, the following protocol-based code snippet will compile:

protocol Animal { }

struct Dog: Animal { }
struct Cat: Animal { }

func getFavouriteAnimal() -> Animal {
  if isDogLover {
    return Dog()
  }
  return Cat()
}

However, using opaque return types:

protocol Animal { }

struct Dog: Animal { }
struct Cat: Animal { }

// Update function to use an opaque return type
func getFavouriteAnimal() -> some Animal {
  if isDogLover {
    return Dog()
  }
  return Cat()
}

The code would fail with the following compiler error:

Function declares an opaque return type, but the return statements in its body do not have matching underlying types

This is because the Swift compiler needs the function to return a single type to “know” the type secret.

Hiding Filters

It’s time to use opaque return types in MagicImage. Open MIFilter.swift. In the Utility Functions section, add the following free function at the bottom of the file:

public func compose<T: MIFilter, U: MIFilter>(
  _ first: T, 
  _ second: U
) -> some MIFilter {
  Compose(first: first, second: second)
}

Here, you define a new function, compose, which wraps the existing Compose filter while returning an opaque type rather than the protocol returned by Compose.

Next, open FiltergramView.swift and update the implementation of oldeMirror in loadImage() so it’s defined like so:

let oldeMirror = compose(Olde(), HorizontalFlip())

If you Option-click oldeMirror, the inferred-type dialog shows its type as some MIFilter.

Inferring the type of Olde Mirror as an opaque return type

Note: If you see an error instead of the result above, build your project by pressing Command-B and try again.

At first glance, an opaque return type looks a lot like a protocol. But there are important differences.

An opaque return type refers to a single specific type. The caller of the function (Abbie) is not let in on the secret, but the compiler is.

With protocols, the return type could be any structure or object that conforms to the protocol. And neither Abbie nor Corrine the compiler learns the secret.

Before continuing, revert the changes to loadImage() so it uses the selectedFilter binding again:

func loadImage() {
  guard let inputImage = UIImage(named: "Butterfly") else { return }

  let uiImage = inputImage.apply(selectedFilter)
  image = Image(uiImage: uiImage)
}

Now you’re going to apply a new technique which will make the code even more beautiful.