Opaque return types in Swift
Discover page available: GenericsIntroduced 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.
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! 🚀