Continuation Type
It’s often the case that the result of a function is not immediately available, but instead the result will be produced after some delay. To prevent our user interface from pausing and being unresponsive, we typically execute such functions off of the main thread. These functions are written to return the type void
, and we get their result by passing in a function to which the result will be passed when it’s ready.
For example:
func getUser(id: UUID, callback: (User) -> ()) {
// ...
}
The function getUser
returns void
, but to get its result, we provide the callback
function that will be passed the resulting User
when it’s ready.
The above function is equivalent to its curried version:
func getUser(id: UUID) -> ((User) -> ()) -> () {
return { callback in
// ...
}
}
Here we change the function from one that requires two arguments to one that takes the first argument and returns a fuction expecting the second. We’d call this second version as follows:
let uuid: UUID = // ...
getUser(id: uuid)({ user in
print(user)
})
We can go further and create a data structure that wraps the second function:
struct UserContinuation {
let operation: (@escaping (User) ->()) -> ()
}
Which can then be generalized for a callback for any type which we’ll call Wrapped
here:
struct Continuation<Wrapped> {
let operation: (@escaping (Wrapped) ->()) -> ()
}
We can then refactor the getUser
function to use this type:
func getUser(id: UUID) -> Continuation<User> {
return Continuation { callback in
// ...
}
}
Now getUser
is written in a way that looks just like synchronous code. We call it, and it returns a User
, except that the User
is wrapped up in a Continuation
. It’s still a User
more or less, just like if it produced an optional User
instead. When we want the actual User
, we must call the operation
function and provide a callback that will get passed the actual User
.
Enabling Code Reuse
We’ve converted code that requires a callback to synchronous code, but what good is a Continuation<User>
to us? We presumably have functions that expect a User but not a Continuation<User>
and as a result, we can’t use that code with our Continuation<User>
. We need a way to call a function that expects a User
, but somehow pass it a Continuation<User>
. The map
function does just that.
extension Continuation {
func map<Mapped>(_ transform: @escaping (Wrapped) -> Mapped) -> Continuation<Mapped> {
return Continuation<Mapped> { callback in
self.operation { value in
let transformed = transform(value)
callback(transformed)
}
}
}
}
This enables us to call any function expecting a User with a Continuation<User>:
func getUsername(from: User) -> String {
// ...
}
let user: Continuation<User> = // ...
let username: Continuation<String> = user.map(getUsername)
This solves our problem of code reuse but with one major limitation; it only works with functions with single arguments.
Reusing Multi-Argument Functions
What if we had a function that expects two instances of User
? We can still reuse such functions, but we must use a different strategy to do so.
First, we must define some custom operators:
infix operator <^>: MultiplicationPrecedence
infix operator <*>: AdditionPrecedence
Next, we’ll use these operators to define some functions.
First, we’ll use <^>
to just be the map
method with the order reversed so the function is the first argument:
extension Continuation {
static func <^> <Mapped>(transform: @escaping (Wrapped) -> Mapped, continuation: Continuation) -> Continuation<Mapped> {
return continuation.map(transform)
}
}
So user.map(getUsername
) is the same as getUsername <^> user
. This gets us closer to the regular function calling syntax.
Next we’ll use <*>
to define the following function:
extension Continuation {
static func <*> <Mapped>(transform: Continuation<(Wrapped) -> Mapped>, continuation: Continuation) -> Continuation<Mapped> {
return Continuation<Mapped> { callback in
transform.operation { fn in
continuation.operation { value in
let transformed = fn(value)
callback(transformed)
}
}
}
}
}
Note how this function has almost the exact same signature as <^>
except its first parameter is a function that’s also wrapped in a Continuation
.
These functions get used together as follows:
func getUsernames(_ user1: User, _ user2: User) -> (String, String) {
// ...
}
let user1: Continuation<User> = // ...
let user2: Continuation<User> = // ...
let names: Continuation<(String, String)> = curried(getUsername) <^> user1 <*> user2
For this to work, we must have a curried version of getUsername. We can write a function that will do this conversion for us:
func curried<A, B, C>(_ fn: @escaping (A, B) -> C) -> (A) -> (B) -> C {
return { a in { b in fn(a, b) } }
}
Sometimes, we’d like to pass in regular values that aren’t wrapped in continuations mixed with wrapped values. To do this we need a way to wrap a value for free. The function pure does this for us:
extension Continuation {
static func pure(_ value: Wrapped) -> Continuation {
return Continuation { callback in callback(value) }
}
}
So, now we can reuse our code regardless of the arity of our functions with our Continuation
type, but one problem still remains. How do we combine multiple functions that return Continuation
instances?
No More Nested Callbacks
Chaining multiple function that produce our Continuation type currently is pretty awkward.
func getUser(id: UUID) -> Continuation<User> {
// ...
}
func hasActiveAccount(user: User) -> Continuation<Bool> {
// ...
}
getUser(id: uuid).operation { user in
hasActiveAccount(user) { isActive in
// ...
}
}
We might be tempted to use the map
method to solve this:
let isActive = getUser(id: uuid).map(hasActiveAccount)
The problem here is that the type of isActive
ends up being Continuation<Continuation<Bool>>
where we’d really like it to be just Continuation<Bool>
. We need a function like map
, but one that also flattens.
extension Continuation {
func flatMap<Mapped>(_ transform: @escaping (Wrapped) -> Continuation<Mapped>) -> Continuation<Mapped> {
return Continuation<Mapped> { callback in
self.operation { value in
let transformed = transform(value)
transformed.operation(callback)
}
}
}
}
Now we can write the following code:
let isActive: Continuation<Bool> = getUser(id: uuid).flatMap(hasActiveAccount)
And the type of isActive is Continuation<Bool>
. No nested callbacks needed. The Continuation
type now handles combining callbacks for us, and we only need to provide a single callback when we want the final result.
A Final Word
These three techniques we’ve discussed here are not my invention. They are based on abstractions from category theory. The map
function corresponds to Functors, <*>
to Applicative Functors, and flatMap
to Monads. The pure
function is part of both Applicatives and Monads. There’s more to know about these. There are rules that proper implementations of these abstractions must follow, relationships between them, and limitations on what types can use these techniques. I leave further exploration of these topics to the reader.