Understanding Opaque Return Types in Swift
Why are SwiftUI's return types some View
?
Why can't it just return a regular protocol?
What are Opaque Types?
Opaque Return Types is a feature added in Swift 5.1 that is a big part of the new SwiftUI framework's functionality. It comes to finally fix a fundamental problem of the usage of protocols and the design of Swift APIs, opening new possibilities for the creation and usage of public APIs.
Building APIs before Swift 5.1
To understand what Opaque Types are, let's see what possibilities we have today when building public APIs.
Let's assume that we have a payments framework that has a method that returns the user's favorite credit card, which is a CreditCard
struct
:
public func favoriteCreditCard() -> CreditCard {
return getLastUsedCreditCard()
}
This can be fine for internal APIs, but for public frameworks this could be not ideal. The user might not need to have access to the CreditCard
type itself -- it could contain information that we don't want the user to be playing with, like how hashing is performed.
You can solve these by carefully choosing which methods are public and which ones are private, but what if you want to completely hide the existence of these types?
Today, you can achieve this with the use of protocols, abstracting the implementation and type details into an unified name:
protocol PaymentType { /* ... */ }
struct CreditCard: PaymentType { /* ... */ }
public func favoriteCreditCard() -> PaymentType {
return getLastUsedCreditCard() // () -> CreditCard
}
With this, we can even rewrite favoriteCreditCard()
into a generic method that can return payment types that are not credit cards:
struct ApplePay: PaymentType { /* ... */ }
func favoritePaymentType() -> PaymentType {
if likesApplePay {
return ApplePay()
} else {
return getLastUsedCreditCard()
}
}
Unfortunately, this usage of protocols has a major issue. Because Swift protocols discard the underlying identity of the type, if your protocol happens to have associated type/Self requirements like ones that inherit from Equatable
, you won't be able to do this at all:
protocol PaymentType: Equatable { /* ... */ }
public func favoriteCreditCard() -> PaymentType {
return getLastUsedCreditCard() // () -> CreditCard
}
// Error: Protocol 'PaymentType' can only be used as a generic constraint because it has Self or associated type requirements
This means that in the very least the API's user will never be able to directly compare two payment types, even if they are the same type underneath:
let creditCard = favoriteCreditCard()
let anotherCreditCard = mostRecentCreditCard()
creditCard == anotherCreditCard // `PaymentType` does not conform to Equatable.
Before Swift 5.1, the solution for this would be to hack your way around generics, turn everything into classes or use type erasure techniques, all of which would make the usage of the API more difficult and bring different types of problems into the app. For example, consider this method:
func getHashedCard() -> HashedObject<CreditCard>
Usage of generics can solve this problem, but they can easily make the API harder to deal with. Perhaps the usage of HashedObject
is important internally, but the user likely doesn't need to know about it -- it would be much better if this was returned as a simple PaymentType
object instead, but the protocol limitations prevent it.
Opaque Return Types
The definite solution for this arrived in Swift 5.1 in the shape of Opaque Return Types. If you have a method that returns a concrete type masked as a protocol — much like our favoriteCreditCard()
example that returns a concrete CreditCard
type masked as a not-too-useful PaymentType
protocol, you can make use of Opaque Return Types by changing the return type to some {type name}
:
public func favoriteCreditCard() -> some PaymentType {
return getLastUsedCreditCard() // () -> CreditCard
}
When this is done, the return type of the method is going to be the actual CreditCard
concrete type, but the compiler is going to pretend that it's the protocol instead. This means that while the API's user will see this as a regular protocol, it will have all the capabilities of the concrete type:
let creditCard = favoriteCreditCard() // 'some PaymentType' returning 'CreditCard'
let anotherCreditCard = mostRecentCreditCard() // 'some PaymentType' returning a 'CreditCard'
creditCard == anotherCreditCard // Now works, because two concrete CreditCards can be compared.
The reason this works is because you're looking at some fancy compiler magic — the return type was CreditCard
all along, it's just being hidden from you for coding purposes. This is what favoriteCreditCard()
looks like after compiling:
let favoriteCreditCardMangledName = "$s3MyApp9favoriteCreditCardQryF"
public func favoriteCreditCard() -> @_opaqueReturnTypeOf(favoriteCreditCardMangledName, 0) {
return getLastUsedCreditCard() // () -> CreditCard
}
All references to the some PaymentType
return of favoriteCreditCard()
are replaced with an internal attribute -- which during execution, will take an identifier and use it to provide the actual return type, CreditCard
, stored in the metadata of the method's AST:
// The definition of favoriteCreditCard() contains:
(opaque_result_decl
(opaque_type interface type='(some PaymentType).Type' naming_decl="favoritePaymentType()" underlying:
substitution τ_0_0 -> CreditCard)))
Thus, while in the IDE you'll be prevented from accessing specific CreditCard
properties, in runtime, this:
public func favoriteCreditCard() -> some PaymentType {
return getLastUsedCreditCard() // () -> CreditCard
}
Is the same as returning CreditCard
directly.
public func favoriteCreditCard() -> CreditCard {
return getLastUsedCreditCard() // () -> CreditCard
}
Why is it useful?
The purpose of Opaque Return Types is to give API users' the capabilities of a concrete type without having to unnecessarily expose it. Sometimes, knowing the underlying type of a protocol isn't needed, but you need its capabilities to proceed. The PaymentType example might be too simple for it, so let's see how this could be applied to types with several internal helper types like lazy
functions:
let lazyMap = [1,2,3].map { $0 * 2 }
let lazyFilter = lazyMap.filter { $0.isMultiple(of: 2) }
let lazyDrop = lazyFilter.drop { $0 != 2 }
The type of lazyMap
is LazyMapSequence<[Int], Int>
, the type of lazyFilter
is LazyFilterSequence<LazyMapSequence<[Int], Int>>
, and the type of lazyDrop
is LazyDropWhileSequence<LazyFilterSequence<LazyMapSequence<[Int], Int>>>
!
Creating a method that returns the base Sequence
protocol will prevent that method's user from using the full type's capabilities, but it would also be crazy to create a method that returns this ultra specific generic type -- the user likely doesn't care which inner helper types are composing this object. With opaque return types, you can safely return it as a normal Sequence
type while still keeping the original type's capabilities.
func getLazyDrop() -> some Sequence {
let lazyMap = [1,2,3].lazy.map { $0 * 2 }
let lazyFilter = lazyMap.filter { $0.isMultiple(of: 2) }
let lazyDrop = lazyFilter.drop { $0 != 2 }
return lazyDrop
}
This is why SwiftUI screens return some View
— you need a concrete object for you to be able to compare, process and position them on the screen, but in most cases, it doesn't matter what the View really is, we just need to know that it is one. In the end, this is a tool meant to make your coding life easier.
Follow me on my Twitter (@rockbruno_), and let me know of any suggestions and corrections you want to share.
References and Good reads
WWDC 2019: What's new in SwiftOpaque Return Types Pull Request