Swift Opaque Result Types AKA Reverse Generics
Instead of trying to explain what they do, let's try to build something, encounter a problem, and see how Opaque Result Types will help us complete our objective.
Suppose we want to write a speech synthesizer library. We will feed it text and it should read it out loud (actually log it to the console).
We would like the text representation to be a little more sophisticated then simple `Strings`.
Let's go ahead and create a protocol that the synthesizer can use. Our text representation should conform to `SoundConvertible`.
protocol SoundConvertible {
func read()
}
The simplest type of text would be a `String`
extension String: SoundConvertible {
func read() {
debugPrint(self)
}
}
What if we wanted something smarter that sounds different depending on some condition? Let’s create struct that reads either the first or the second depending on some condition (looks familiar?).
struct ConditionalSentence<T: SoundConvertible, U: SoundConvertible>: SoundConvertible {
let first: T
let second: U
let condition: Bool
func read() {
if condition {
first.read()
} else {
second.read()
}
}
}
A way to merge sentences would be useful:
struct Sentence<T: SoundConvertible, U: SoundConvertible>: SoundConvertible {
let first: T
let second: U
func read() {
first.read()
second.read()
}
}
The combination of the above will allow us to create sentences with arbitrary size.
Let's go ahead and create our module's public API that constructs a `SoundConvertible` that says:
"The <subject> looks great" if the sentence's subject is "iPhone" or "The <subject> looks not so great" if the subject is something else.
final class SpeechSynthesizer {
/// Constructs a readable sentence from the parts provided.
/// - Parameters:
/// - subject: The subject of the sentence or "who".
/// - verb: The verb of the sentence.
/// - condition: If true the synthesizer will read the positive and if false the negative text.
/// - positive: The text that will be read if condition is true.
/// - negative: The text that will be read if condition is false
/// - Returns: The constructed sentence, ready to be spoken.
public func readSentence(
subject: String,
verb: String,
condition: Bool,
positive: String,
negative: String
) -> Sentence<String, Sentence<String, ConditionalSentence<String, String>>> {
Sentence(
first: subject,
second: Sentence(
first: verb,
second: ConditionalSentence(
first: positive,
second: negative,
condition: condition
)
)
)
}
}
Notice how we are able combine SoundConvertible types with each other to form large sentences.
The client can use it like so:
let subject = "iPhone"
...
let synthesizer = SpeechSynthesizer()
let sentence = synthesizer.readSentence(subject: "The \(subject)",
verb: "looks",
condition: subject == "iPhone" ,
positive: "great",
negative: "not so great")
sentence.read()
Our solution so far works but comes with 2 drawbacks.
The first is that the actual type of the sentence is leaked to the client.
Sentence<String, Sentence<String, ConditionalSentence<String, String>>>
In this kind of composition, if we decide to return a slightly larger sentence, the actual types can become very large very quickly for example:
Sentence<Sentence<String, Sentence<String, Sentence<String, ConditionalSentence<String, String>>>>, Sentence<String, Sentence<String, ConditionalSentence<String, String>>>>
We might decide to change our underlying types and use something different. There is no real value in exposing these "Internal" types to the clients.
This can be solved with the usage of protocols. The `readSentence()` function could be refactored to return a type that conforms to "SoundConvertible" like so:
public func readSentence(
subject: String,
verb: String,
condition: Bool,
positive: String,
negative: String
) -> SoundConvertible { ... }
But the usage of protocols imposes a restriction to our API. When we use protocols in this case, we lose the ability to make our return type conform to protocols that have associated types for example "Equatable", "Hashable" and so on.
If we refactor "SoundConvertible" to conform to "Equatable" we now get a compiler error because protocols that have an associated type can only be used as generic constraints and cannot be used as return types, not without "any" (spoiler: "any" will allow us to return a "SoundConvertible" but we will not be able to use is as an "Equatable").
In regular Swift code we would use some form of generics to overcome this issue. The problem in our case is that when we use generics in Swift the actual type that will be used is actually chosen by the client. What we are trying to achieve here is the oposite, we want to hide the actual type from the client.
We can do this by using the "some" keyword followed by the protocol.
protocol SoundConvertible: Equatable {
func read()
}
public func makeSentence(
subject: String,
verb: String,
condition: Bool,
positive: String,
negative: String
) -> some SoundConvertible {
Sentence(
first: subject,
second: Sentence(
first: verb,
second: ConditionalSentence(
first: positive,
second: negative,
condition: condition
)
)
)
}
What this does is that "some" hides the actual type from the client but not from the compiler. This is similar to a generic function but in reverse, the function choses the concrete type and not the client.
This allows us to compare "some SoundConvertible" created by the same function with each other like so:
let subject = "iPhone"
let synthesizer = SpeechSynthesizer()
let sentence1 = synthesizer.makeSentence(subject: "The \(subject)",
verb: "looks",
condition: subject == "iPhone" ,
positive: "great",
negative: "not so great")
let subject2 = "Android"
let sentence2 = synthesizer.makeSentence(subject: "The \(subject2)",
verb: "looks",
condition: subject2 == "iPhone" ,
positive: "great",
negative: "not so great")
sentence1 == sentence2 // false
Although the usage of "some" allows us to compare the two sentences, if we create a second function that also returns "some" the compiler will not allow us to compare them and emit a surprisingly helpful compiler error message.
let sentence1 = synthesizer.makeSentence(subject: "The \(subject)",
verb: "looks",
condition: subject == "iPhone" ,
positive: "great",
negative: "not so great")
let sentence2 = synthesizer.make(simple: "Simple sentence")
sentence1 == sentence2 // error: Cannot convert value of type 'some SoundConvertible' (result of 'SpeechSynthesizer.read(simple:)') to expected argument type 'some SoundConvertible' (result of 'SpeechSynthesizer.readSentence(subject:verb:condition:positive:negative:)')
Opaque types can only be compared with the other Opaque types that have been created by the same function.
Are opaque types used anywhere ?
They are extensively used in SwiftUI. Specifically the @ViewBuilder that creates the SwiftUI Views actually returns an opaque type "some View".
With a small trick, we can actually see the hidden types that are private to SwiftUI and hidden behind "some View"
Let's create a SwiftUI View
var r = HStack {
Text("Hello")
if true {
VStack {
Text("Aris")
}
} else {
Text("iOS")
}
}
If we Option + Click on "r" in Xcode we see the actual type: HStack<TupleView<(Text, _ConditionalContent<VStack<Text>, Text>)>>
Lets break it down:
var r = HStack { // HStack<
// TupleView< -> Displays 2 views
Text("Hello") // (Text,
if true { // _ConditionalContent< -> Displays one or the other depending on some condition
VStack { // VStack<
Text("Aris") // Text>
} //>
} else { //
Text("iOS") //Text>)>>
}
}
In a future article we will explore how the SwiftUI @ViewBuilder works and how it converts the view body to an actual type.