Home

Awesome

Tomorrowland

Version Platforms Languages License CocoaPods Carthage compatible

Tomorrowland is an implementation of Promises for Swift and Objective-C. A Promise is a wrapper around an asynchronous task that provides a standard way of subscribing to task resolution as well as chaining promises together.

UIApplication.shared.isNetworkActivityIndicatorVisible = true
MyAPI.requestFeed(for: user).then { (feedItems) in
    self.refreshUI(with: feedItems)
}.catch { (error) in
    self.showError(error)
}.always { _ in
    UIApplication.shared.isNetworkActivityIndicatorVisible = false
}

It is loosely based on both PromiseKit and Hydra, with a few key distinctions:

Installation

Manually

You can add Tomorrowland to your workspace manually like any other project and add the resulting Tomorrowland.framework to your application's frameworks.

Carthage

github "lilyball/Tomorrowland" ~> 1.0

The project file is configured to use Swift 5. The code can be compiled against Swift 4.2 instead, but I'm not aware of any way to instruct Carthage to override the swift version during compilation.

CocoaPods

pod 'Tomorrowland', '~> 1.0'

The podspec declares support for both Swift 4.2 and Swift 5.0, but selecting the Swift version requires using CoocaPods 1.7.0 or later. When using CocoaPods 1.6 or earlier the Swift version will default to 5.0.

SwiftPM

Tomorrowland currently relies on a private Obj-C module for its atomics. This arrangement means it is not compatible with Swift Package Manager (as adding compatibility would necessitate publicly exposing the private Obj-C module).

Usage

Creating Promises

Promises can be created using code like the following:

let promise = Promise<String,Error>(on: .utility, { (resolver) in
    let value = try expensiveCalculation()
    resolver.fulfill(with: value)
})

The body of this promise runs on the specified PromiseContext, which in this case is .utility (which means DispatchQueue.global(qos: .utility)). Unlike callbacks, all created promises must specify a context, so as to avoid accidentally running expensive computations on the main thread. The available contexts include .main, every Dispatch QoS, a specific DispatchQueue, a specific OperationQueue, or the value .immediate which means to run the block synchronously. There's also the special context .auto, which evaluates to .main on the main thread and .default otherwise.

Note: The .immediate context can be dangerous to use for callback handlers and should be avoided in most cases. It's primarily intended for creating promises, and whenever it's used with a callback handler the handler must be prepared to execute on any thread. For callbacks it's usually only useful for short thread-agnostic callbacks, such as an .onRequestCancel that does nothing more than cancelling a URLSessionTask.

The body of a Promise receives a "resolver", which it must use to fulfill, reject, or cancel the promise. If the resolver goes out of scope without being used, the promise is automatically cancelled. If the promise's error type is Error, the promise body may also throw an error (as seen above), which is then used to reject the promise. This resolver can also be used to observe cancellation requests using resolver.onRequestCancel, as seen here:

let promise = Promise<Data,Error>(on: .immediate, { (resolver) in
    let task = urlSession.dataTask(with: url, completionHandler: { (data, response, error) in
        if let data = data {
            resolver.fulfill(with: data)
        } else if case URLError.cancelled? = error {
            resolver.cancel()
        } else {
            resolver.reject(with: error!)
        }
    })
    resolver.onRequestCancel(on: .immediate, { _ in
        task.cancel()
    })
    task.resume()
})

Resolvers also have a convenience method handleCallback() that is intended to make it easy to wrap framework callbacks in promises. This method returns a closure that can be used as a callback directly. It also takes an optional isCancelError parameter that can be used to indicate when an error represents cancellation. For example:

geocoder.reverseGeocodeLocation(location, completionHandler: resolver.handleCallback(isCancelError: { CLError.geocodeCanceled ~= $0 }))

Using Promises

Once you have a promise, you can register callbacks to be executed when the promise is resolved. Most callback methods require a context, but for some of them (then, catch, always, and tryThen) you can omit the context and it will default to .auto, which means the main thread if the callback is registered from the main thread, otherwise the dispatch queue with QoS .default.

When you register a callback, the method also returns a Promise. All callback registration methods return a new Promise even if the callback doesn't affect the value of the promise. The reason for this is so chained callbacks always guarantee that the previous callback finished executing before the new one starts, even when using concurrent contexts (e.g. .utility), and so cancelling the returned promise doesn't cancel the original one if any other callbacks were registered on it.

Most callback registration methods also have versions that allow you to return a Promise from your callback. In this event, the resulting Promise waits for the promise you returned to resolve before adopting its value. This allows for easy composition of promises.

showLoadingIndicator()
fetchUserCredentials().flatMap(on: .default) { (credentials) in
    // This returns a new promise
    return MyAPI.login(name: credentials.name, password: credentials.password)
}.then { [weak self] (apiKey) in
    // this is invoked when the promise returned by MyAPI.login fulfills.
    MyAPI.apiKey = apiKey
    self?.transitionToLoggedInState()
}.always { [weak self] _ in
    // This is always invoked regardless of whether the previous chain was
    // fulfilled, rejected, or cancelled.
    self?.hideLoadingIndicator()
}.catch { [weak self] (error) in
    // this handles any error returned from the previous chain, meaning any error
    // from `fetchUserCredentials()` or from `MyAPI.login(name:password:)`.
    self?.displayError(error)
}

When composing callbacks that return promises, you may run into issues with incompatible error types. There are convenience methods for working with promises whose errors are compatible with Error, but they don't cover all cases. If you find yourself hitting one of these cases, any Promise whose error type conforms to Error has a property .upcast that will convert that error into an Error to allow for easier composition of promises.

Tomorrowland also offers a typealias StdPromise<Value> as shorthand for Promise<T,Error>. This is frequently useful to avoid having to repeat the types, such as with StdPromise(fulfilled: someValue) instead of Promise<SomeValue,Error>(fulfilled: someValue).

Cancelling and Invalidation

All promises expose a method .requestCancel(). It is named such because this doesn't actually guarantee that the promise will be cancelled. If the promise supports cancellation, this method will trigger a callback that the promise can use to cancel its work. But promises that don't support cancellation will ignore this and will eventually fulfill or reject as normal. Naturally, requesting cancellation of a promise that has already been resolved does nothing, even if the callbacks have not yet been invoked.

In order to handle the issue of a promise being resolved after you no longer care about it, there is a separate mechanism called a PromiseInvalidationToken that can be used to suppress callbacks. All callback methods have an optional token parameter that accepts a PromiseInvalidationToken. If provided, calling invalidate() on the token prior to the callback being executed guarantees the callback will not fire. If the callback returns a value that is required in order to resolve the Promise returned from the callback registration method, the resulting Promise is cancelled instead. PromiseInvalidationTokens can be used with multiple callbacks at once, and a single token can be re-used as much as desired. It is recommended that you take advantage of both invalidation tokens and cancellation. This may look like

class URLImageView: UIImageView {
    private var promise: StdPromise<Void>?
    private let invalidationToken = PromiseInvalidationToken()
    
    enum LoadError: Error {
        case dataIsNotImage
    }
    
    /// Loads an image from the URL and displays it in the image view.
    func loadImage(from url: URL) {
        promise?.cancel()
        invalidationToken.invalidate()
        // Note: dataTaskAsPromise does not actually exist
        promise = URLSession.shared.dataTaskAsPromise(with: url)
        // Use `_ =` to avoid having to handle errors with `.catch`.
        _ = promise?.tryMap(on: .utility, { (data) -> UIImage in
            if let image = UIImage(data: data) {
                return image
            } else {
                throw LoadError.dataIsNotImage
            }
        }).then(token: invalidationToken, { [weak self] (image) in
            self?.image = image
        })
    }
}

PromiseInvalidationToken also has a method .requestCancelOnInvalidate(_:) that can register any number of Promises to be automatically requested to cancel (using .requestCancel()) the next time the token is invalidated. Promise also has the same method (except it takes a token as the argument) as a convenience for calling .requestCancelOnInvalidate(_:) on the token. This can be used to terminate a promise chain without ever assigning the promise to a local variable. PromiseInvalidationToken also has a method .cancelWithoutInvalidating() which cancels any associated promises without invalidating the token.

By default PromiseInvalidationTokens will invalidate themselves automatically when deinitialized. This is primarily useful in conjunction with requestCancelOnInvalidate(_:) as it allows you to automatically cancel your promises when object that owns the token deinits. This behavior can be disabled with an optional parameter to init.

Promise also has a convenience method requestCancelOnDeinit(_:) which can be used to request the Promise to be cancelled when a given object deinits. This is equivalent to adding a PromiseInvalidationToken property to the object (configured to invalidate on deinit) and requesting cancellation when the token invalidates, but can be used if the token would otherwise not be explicitly invalidated.

Using these methods, the above loadImage(from:) can be rewritten as the following including cancellation:

class URLImageView: UIImageView {
    private let promiseToken = PromiseInvalidationToken()
    
    enum LoadError: Error {
        case dataIsNotImage
    }
    
    /// Loads an image from the URL and displays it in the image view.
    func loadImage(from url: URL) {
        promiseToken.invalidate()
        // Note: dataTaskAsPromise does not actually exist
        promise = URLSession.shared.dataTaskAsPromise(with: url)
        // Use `_ =` to avoid having to handle errors with `.catch`.
        _ = promise?.tryMap(on: .utility, { (data) -> UIImage in
            if let image = UIImage(data: data) {
                return image
            } else {
                throw LoadError.dataIsNotImage
            }
        }).then(token: promiseToken, { [weak self] (image) in
            self?.image = image
        }).requestCancelOnInvalidate(invalidationToken)
    }
}

Invalidation token chaining

PromiseInvalidationTokens can be arranged in a tree such that invalidating one token will cascade this invalidation down to other tokens. This is accomplished by calling childToken.chainInvalidation(from: parentToken). Practically speaking this is no different than just manually invalidating each child token yourself after invalidating the parent token, but it's provided as a convenience to make it easy to have fine-grained invalidation control while also having a simple way to bulk-invalidate tokens. For example, you might have separate tokens for different view controllers that all chain invalidation from a single token that gets invalidated when the user logs out, thus automatically invalidating all your user-dependent network requests at once while still allowing each view controller the ability to invalidate just its own requests independently.

TokenPromise

In order to avoid the repetition of passing a PromiseInvalidationToken to multiple Promise methods as well as cancelling the resulting promise, a type TokenPromise exists that handles this for you. You can create a TokenPromise with the Promise.withToken(_:) method. This allows you to take code like the following:

func loadModel() {
    promiseToken.invalidate()
    MyModel.fetchFromNetworkAsPromise()
        .then(token: promiseToken, { [weak self] (model) in
            self?.updateUI(with: model)
        }).catch(token: promiseToken, { [weak self] (error) in
            self?.handleError(error)
        }).requestCancelOnInvalidate(promiseToken)
}

And rewrite it to be less repetitive:

func loadModel() {
    promiseToken.invalidate()
    MyModel.fetchFromNetworkAsPromise()
        .withToken(promiseToken)
        .then({ [weak self] (model) in
            self?.updateUI(with: model)
        }).catch({ [weak self] (error) in
            self?.handleError(error)
        })
}

Automatic cancellation propagation

Nearly all callback registration methods will automatically propagate cancellation requests from the child to the parent if the parent has no other observers. If all observers for a promise request cancellation, the cancellation request will propagate upwards at this time. This means that a promise will not automatically cancel as long as there's at least one interested observer. Do note that promises that have no observers do not get automatically cancelled, this only happens if there's at least one observer (which then requests cancellation). Automatic cancellation propagation also requires that the promise itself no longer be in scope. For this reason you should avoid holding onto promises long-term and instead use the .cancellable property or PromiseInvalidationToken's requestCancelOnInvalidate(_:) if you want to be able to cancel the promise later.

Automatic cancellation propagation also works with the utility functions when(fulfilled:) and when(first:) as well as the convenience methods timeout(on:delay:) and delay(on:_:).

Promises have a couple of methods that do not participate in automatic cancellation propagation. You can use tap(on:token:_:) as an alternative to always in order to register an observer that won't interfere with the existing automatic cancellation propagation (this is suitable for inserting into the middle of a promise chain). You can also use tap() as a more generic version of this.

Note that ignoringCancel() disables automatic cancellation propagation on the receiver. Once you invoke this on a promise, it will never automatically cancel.

propagatingCancellation(on:cancelRequested:)

In some cases you may need to hold onto a promise without blocking cancellation propagation from its children. The primary use-case here is deduplicating access to an asynchronous resource (such as a network load). In this scenario you may wish to hold onto a promise and return a new child for every client requesting the same resource, without preventing cancellation of the resource load if all clients cancel their requests. This can be accomplished by holding onto the result of calling .propagatingCancellation(on:cancelRequested:). The promise returned from this method will propagate cancellation to its parent as soon as all children have requested cancellation even if the promise is still in scope. When cancellation is requested, the cancelRequested handler will be invoked immediately prior to propagating cancellation upwards; this enables you to release your reference to the promise (so a new request by a client will create a brand new resource load). Returning a new child to each client can be done using makeChild(). An example of this might look like:

func loadResource(at url: URL) {
    let promise: StdPromise<Model>
    if let existingPromise = resourceLoads[url] {
        promise = existingPromise
    } else {
        promise = makeResourceRequest(for: url).propagatingCancellation(on: .main, cancelRequested: { (promise) in
            if self.resourceLoads[url] == promise {
                self.resourceLoads[url] = nil
            }
        })
        resourceLoads[url] = promise
    }
    // Return a new child for each request so all clients have to cancel, not just one.
    return promise.makeChild()
}

The special .nowOr(_:) context

There is a special context PromiseContext.nowOr(_:) that behaves a bit differently than other contexts. This context is special in that its callback executes differently depending on whether the promise it's being registered on has already resolved by the time the callback is registered. If the promise has already resolved then .nowOr(context) behaves like .immediate, otherwise it behaves like the wrapped context. This context is intended to be used to replace code that would otherwise check if the promise.result is non-nil prior to registering a callback.

If this context is used in Promise.init(on:_:) it always behaves like .immediate, and if it's used in DelayedPromise.init(on:_:) it always behaves like the wrapped context.

There is a property PromiseContext.isExecutingNow that can be accessed from within a callback registered with .nowOr(_:) to determine if the callback is executing synchronously or asynchronously. When accessed from any other context it returns false. When registering a callback with .immediate from within a callback where PromiseContext.isExecutingNow is true, the nested callback will inherit the PromiseContext.isExecutingNow flag if and only if the nested callback is also executing synchronously. This is a bit subtle but is intended to allow Promise(on: .immediate, { … }) to inherit the flag from its surrounding scope.

An example of how this context might be used is when populating an image view from a network request:

createNetworkRequestAsPromise()
    .then(on: .nowOr(.main), { [weak imageView] (image) in
        guard let imageView = imageView else { return }
        let duration: TimeInterval = PromiseContext.isExecutingNow
            ? 0 // no transition if we're synchronous
            : 0.25
        UIView.transition(with: imageView, duration: duration, options: .transitionCrossDissolve, animations: {
            imageView.image = image
        })
    })

Promise Helpers

There are a few helper functions that can be used to deal with multiple promises.

when(fulfilled:)

when(fulfilled:) is a global function that takes either an array of promises or 2–6 promises as separate arguments, and returns a single promise that is eventually fulfilled with the values of all input promises. With the array version all input promises must have the same type and the result is fulfilled with an array. With the separate argument version the promises may have unique value types (but the same error type) and the result is fulfilled with a tuple.

If any of the input promises is rejected or cancelled, the resulting promise is immediately rejected or cancelled as well. If multiple input promises are rejected or cancelled, the first such one affects the result.

This function has an optional parameter cancelOnFailure: that, if provided as true, will cancel all input promises if any of them are rejected.

when(first:)

when(first:) is a global function that takes an array of promises of the same type, and returns a single promise that eventually adopts the same value or error as the first input promise that gets fulfilled or rejected. Cancelled input promises are ignored, unless all input promises are cancelled, at which point the resulting promise will be cancelled as well.

This function has an optional parameter cancelRemaining: that, if provided as true, will cancel the remaining input promises as soon as one of them is fulfilled or rejected.

Promise.timeout(on:delay:)

Promise.timeout(on:delay:) is a method that returns a new promise that adopts the same value as the receiver, or is rejected with an error if the receiver isn't resolved within the given interval.

Promise.delay(on:_:)

Promise.delay(on:_:) is a method that returns a new promise that adopts the same result as the receiver after the specified delay. It is intended primarily for testing purposes.

PromiseOperation

PromiseOperation is an Operation subclass that wraps a Promise and allows for delayed execution of the promise handler. It's created just like Promise, with init(on:_:), but it doesn't run the handler until the operation is started (either by calling start() or by adding it to an OperationQueue). The operation has a .promise property that returns a Promise that will resolve to the results of the computation, but can be accessed before the handler is invoked. If the operation is put on a queue and is initialized with the .immediate context, the provided handler will run on the queue.

Requesting cancellation of the PromiseOperation.promise is identical to calling PromiseOperation.cancel(). If the operation has already started, cancellation support is at the discretion of the provided handler, just like with a normal Promise. If the operation has not yet started, cancelling it will prevent the handler from ever executing, though the returned promise itself won't cancel until the operation has moved to the isFinished state (e.g. by being started).

The use of PromiseOperation instead of a Promise allows for delaying execution of the promise, setting up dependencies, controlling concurrency with the operation queue's maxConcurrentOperationCount, and generally integrating with existing operation queues.

Objective-C

Tomorrowland has Obj-C compatibility in the form of TWLPromise<ValueType,ErrorType>. This is a parallel promise implementation that can be bridged to/from Promise and supports all of the same functionality. Note that some of the method names are different (due to lack of overloading), and while TWLPromise is generic over its types, the return values of callback registration methods that return new promises are not parameterized (due to inability to have generic methods).

Callback lifetimes

Callbacks registered on promises will be retained until the promise is resolved. If a callback is invoked (or would be invoked if the relevant invalidation token hadn't been invalidated), Tomorrowland guarantees that it will release the callback on the context it was invoked on. If the callback is not invoked (e.g. it's a then(on:_:) callback but the promise was rejected) then no guarantees are made as to the context the callback is released on. If you need to ensure it's released on the appropriate context (e.g. if it captures an object that must deallocate on the main thread) then you can use .always or one of the .mapResult variants.

Requirements

Requires a minimum of iOS 9, macOS 10.10, watchOS 2.0, or tvOS 9.0.

License

Licensed under either of

Contribution

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you shall be dual licensed as above, without any additional terms or conditions.

Version History

Development

v1.4.0

v1.3.0

v1.2.0

v1.1.1

v1.1.0

v1.0.1

v1.0.0

v0.6.0

v0.5.1

v0.5.0

v0.4.3

v0.4.2

v0.4.1

v0.4

v0.3.4

v0.3.3

v0.3.2

v0.3.1

v0.3

v0.2

v0.1

Initial alpha release.