Articles, podcasts and news about Swift development, by John Sundell.

Opaque return types in Swift

Published on 12 Jul 2020
Discover page available: Generics

Introduced in Swift 5.1, opaque return types is a language feature perhaps most associated with SwiftUI and the some View type used when building views using it. But just like the other Swift features that power SwiftUI’s DSL, opaque return types is a general-purpose feature that can be used in many different contexts.

This week, let’s take a closer look at opaque return types — how they can be used both with and without SwiftUI, and how they compare to similar generic programming techniques, such as type erasure.

Inferred, hidden return types

Opaque return types essentially enable us to do two things. First, they let us leverage the Swift compiler’s type inference capabilities to avoid having to declare exactly what type that a given function or computed property will return, and second, they hide those inferred types from the callers of those APIs.

To take a look at what that means in practice, let’s say that we’ve built the following SwiftUI view, which renders two Text views vertically using a VStack:

struct TextView: View {
    var title: String
    var subtitle: String

    var body: some View {
        VStack(alignment: .leading) {
            Text(title).bold()
            Text(subtitle).foregroundColor(.secondary)
        }
    }
}

Above we’re using an opaque return type, some View, for our view’s body, which is a common convention when building SwiftUI views.

The reason for that is that SwiftUI makes heavy use of Swift’s type system in order to perform tasks like diffing, and to ensure full type safety throughout our view hierarchy — which means that we’ll end up with quite complex return types, even for simple views, since our entire view hierarchy essentially gets encoded into the type system itself.

For example, the above view’s body results in the following type:

VStack<TupleView<(Text, Text)>>

If you’re currently working on a SwiftUI-based project, try calling type(of:) on the body of one of your views, and I’m sure you’ll see an even more complex type, especially if that view has modifiers applied to it.

So the fact that we don’t have to explicitly specify the exact types of our SwiftUI views is a really good thing, since otherwise we’d have to modify each view’s body return type whenever we changed its hierarchy, which would make SwiftUI much harder to use.

Single return types required

However, using an opaque return type does require all of the code branches within a given function or property to always return the exact same type — since otherwise the compiler wouldn’t be able to infer that type for us. That can lead to some tricky situations whenever we’re dealing with some form of conditional logic, for example in order to determine whether to show a loading spinner or the actual content of a SwiftUI view — like in this ProductView:

struct ProductView: View {
    @ObservedObject var viewModel: ProductViewModel

    var body: some View {
        switch viewModel.state {
        case .isLoading:
            return Wrap(UIActivityIndicatorView()) {
                $0.startAnimating()
            }
        case .finishedLoading:
            return TextView(
                title: viewModel.productName,
                subtitle: viewModel.formattedPrice
            )
        }
    }
}

Above we’re using the Wrap view from “Inline wrapping of UIKit or AppKit views within SwiftUI” to be able to easily bring UIKit’s UIActivityIndicatorView into SwiftUI.

Attempting to compile the above code will give us the following error:

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

One way to fix that problem would be to use type erasure to give each of our two switch cases the same return type — AnyView in this case — which is a built-in wrapper that lets us erase the underlying type of a SwiftUI view, like this:

struct ProductView: View {
    @ObservedObject var viewModel: ProductViewModel

    var body: some View {
        switch viewModel.state {
        case .isLoading:
            return AnyView(Wrap(UIActivityIndicatorView()) {
                $0.startAnimating()
            })
        case .finishedLoading:
            return AnyView(TextView(
                title: viewModel.productName,
                subtitle: viewModel.formattedPrice
            ))
        }
    }
}

Our code now compiles, but always having to perform the above kind of wrapping whenever we have some form of condition within one of our views can get a bit tedious, so let’s explore a few other routes as well.

In Swift 5.3, two key changes have been made to the function builders feature that SwiftUI uses to enable multiple separate expressions to be combined into a single return type. First, we can now use switch statements within a function builder-powered function, property or closure — and second, each view body now inherits the @ViewBuilder attribute from the declaration of the View protocol itself.

What that means in this context is that once we’re ready to upgrade to Swift 5.3 and Xcode 12, we’ll be able to refactor the above ProductView by removing both the usages of AnyView, and our return keywords — which gives us the following implementation:

struct ProductView: View {
    @ObservedObject var viewModel: ProductViewModel

    var body: some View {
        switch viewModel.state {
        case .isLoading:
            ProgressView()
        case .finishedLoading:
            TextView(
                title: viewModel.productName,
                subtitle: viewModel.formattedPrice
            )
        }
    }
}

Above we’ve also replaced our inline wrapping of UIActivityIndicatorView with the ProgressView that now comes built-in on Apple’s various platforms.

That’s really cool, but what’s perhaps even more interesting is that we can actually replicate more or less the exact same behavior in Swift 5.2 as well. By manually adding the @ViewBuilder attribute to our view’s body property, we can use a combined if/else statement to implement the same sort of conditional logic, albeit in a slightly less future-proof way compared to when using an exhaustive switch statement:

struct ProductView: View {
    @ObservedObject var viewModel: ProductViewModel

    @ViewBuilder var body: some View {
        if viewModel.state == .isLoading {
            Wrap(UIActivityIndicatorView()) {
                $0.startAnimating()
            }
        } else {
            TextView(
                title: viewModel.productName,
                subtitle: viewModel.formattedPrice
            )
        }
    }
}

So within the context of SwiftUI, opaque return types are used to let us return any View expression from our body implementations, without having to specify any explicit types — as long as each code branch returns the same type, which in many cases can be accomplished using ViewBuilder.

Type erasure beyond SwiftUI

Now let’s step outside the realm of SwiftUI and explore how opaque return types could be used within other contexts as well. An initial use case that we might think of could be to use an opaque return type to perform automatic type erasure — for example to be able to return some Publisher when building up a Combine-powered data pipeline, rather than having to specify exactly what type of publisher that a given expression returns, like this:

struct ModelLoader<Model: Decodable> {
    var urlSession = URLSession.shared
    var decoder = JSONDecoder()
    var url: URL

    func load() -> some Publisher {
        urlSession.dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Model.self, decoder: decoder)
    }
}

While the above use case is, on the surface, extremely similar to how opaque return types are used within the context of SwiftUI — we’ll end up with a quite substantial problem in this case.

One key way that opaque types differ from conventional type erasure is that they don’t preserve any information about their underlying types, meaning that the publisher returned from the above load method won’t have any awareness of the generic Model type that’s being loaded.

So in case we wish to preserve that sort of type information, which we definitely do in this case, then it’s better to use type erasure instead — which can be done using AnyPublisher and the eraseToAnyPublisher operator when using Combine:

struct ModelLoader<Model: Decodable> {
    var urlSession = URLSession.shared
    var decoder = JSONDecoder()
    var url: URL

    func load() -> AnyPublisher<Model, Error> {
        urlSession.dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: Model.self, decoder: decoder)
            .eraseToAnyPublisher()
    }
}

However, there are also situations in which we might want to discard the above kind of generic type information, and in those kinds of situations, opaque return types can prove to be incredibly useful.

Using protocol types directly

Let’s take a look at a final example, in which we’ve defined a Task protocol used to model a series of asynchronous tasks that can either succeed or fail, without returning any particular value:

protocol Task {
    typealias Handler = (Result<Void, Error>) -> Void
    func perform(then handler: @escaping Handler)
}

With the above approach, we can currently use our Task protocol directly, and reference it just like any other type, for example by returning a conforming instance from a method, like this:

struct DataUploader {
    var fileManager = FileManager.default
    var urlSession = URLSession.shared

    func taskForUploading(_ data: Data, to url: URL) -> Task {
        let file = File(data: data, manager: fileManager)

        return FileUploadingTask(
            file: file,
            url: url,
            session: urlSession
        )
    }
}

However, if we ever want to add any Self or associated type requirements to our Task protocol, then we’ll start running into problems. For example, we might want to require all tasks to also conform to the built-in Identifiable protocol, in order to be able to track each task based on its ID:

protocol Task: Identifiable {
    typealias Handler = (Result<Void, Error>) -> Void
    func perform(then handler: @escaping Handler)
}

When making the above change, we’ll now start getting the following type of compiler error whenever we’re referencing Task directly, for example within the above DataUploader implementation:

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

This is the type of situation in which using an opaque return type can be a great option, since by just adding the some keyword in front of Task, we’ll be able to keep using the exact same implementation as before:

struct DataUploader {
    ...

    func taskForUploading(_ data: Data, to url: URL) -> some Task {
        ...
    }
}

An alternative to the above approach would be to instead use the concrete type that we’re actually returning as our method’s return type (FileUploadingTask in the above case), but that’s not always practical or something that we want to expose as part of our public API.

As an additional example, let’s say that we wanted to add a convenience API that lets us easily chain one task to another. To make that happen, we might create a private ChainedTask type, that takes two Task-conforming instances and calls them both in sequence when performed — like this:

private struct ChainedTask<First: Task, Second: Task>: Task {
    let id = UUID()
    var first: First
    var second: Second

    func perform(then handler: @escaping Handler) {
        first.perform { [second] result in
            switch result {
            case .success:
                second.perform(then: handler)
            case .failure:
                handler(result)
            }
        }
    }
}

Note that we still have to use generic types for our first and second properties, since the some keyword can’t be applied to properties which the compiler can’t infer a concrete type for.

To then create a public API for our new ChainedTask type, we could use a very SwiftUI-like design and extend the Task protocol itself with a method that returns a ChainedTask instance hidden behind an opaque some Task return type:

extension Task {
    func chained<T: Task>(to nextTask: T) -> some Task {
        ChainedTask(first: self, second: nextTask)
    }
}

With the above in place, we can now combine two separate tasks into one, all without having to know anything about the underlying types that are actually performing our work:

let uploadingTask = uploader.taskForUploading(data, to: url)
let confirmationTask = ConfirmationUITask()
let chainedTask = uploadingTask.chained(to: confirmationTask)

chainedTask.perform { result in
    switch result {
    case .success:
        // Handle successful outcome
    case .failure(let error):
        // Error handling
    }
}

So opaque return types can also be used to hide generic type information behind a much simpler public API, which can be a great technique to keep in mind, especially when building reusable Swift libraries.

Conclusion

Opaque return types can definitely be described as a somewhat niche language feature outside of the realm of SwiftUI, but learning how they work can still be really valuable — both to be able to more easily understand issues that we might encounter when using them within SwiftUI views, but also since they could prove to be really useful when working with generic protocols as well.

Hopefully this article has given you a starting point to do just that, and has shown a few concrete examples of how opaque return types might be used in various contexts. If you have questions, comments or feedback, feel free to reach out either via Twitter or email.

Thanks for reading! 🚀