Xcode at scale - Introducing to Promises - Creating our own framework
This is the tenth step of a serie named iOS at Scale based on the next steps:
- Introduction
- Xcode at Scale
- Refactor MarvelClient - split client from logic
- Refactor DataProvidersKit - applying Iversion of Control
- Refactor DetailKit - Single data flow, states, type erasure and more
- Refactor Navigator - Back to simplest
- Refactor AppCore - we really need it?
- Dependency Injection - flexible and composable
- Extra ball: Cache made easy
- Extra ball: Introducing to Promises 1
- Extra ball: Introducing to Promises 2
- Final conclusions
Since Swift it’s here, the way of coding have changed. This is a Fact. On the old obj-c times are gone, on old times we only have Protocols and Clases, and we must build our code over that. We suffer a explosion of classes when doing architectures like VIPER, we was lead to build new classes wrapping other components, add extra layers of complexity, and in the end, write a lot of more code.
Generic and functions as a type bring us the option to start building very smalls components, with a very closed responsability, and start combining them.
Since creation, Swift it’s a game changer.
We have new tools that may help us on our daily basics coding. Our mindset have to change. We still use the same principles, Solid, Kiss, OOP, and the most common design patterns are still here, but new friends are come too, we have Type Erasure, POP, Closures… And now that it’s providing his own RX framework like Combine, framework where Swift UI it’s built, don’t understand how these tools works is not an option.
We’re going to make a little step inside functional programming, we’re going to create a very little Promises framework, with a couple of clases, to lose the fear to this concepts, also, we’re cover that we don’t need a big external library like PromiseKit, Google Promises, or even RxSwift to get the benefits of FPP.
Lets start making!
Please go to start-promises
on git (git checkout start-promises
).
To generate the project with Tuist we must run tuist generate
on the Workspace.swift
folder. Check the a little bit the code and let’s stop to think one minute.
The first step it’s create our own Package
as we are going to add only code, SPM it’s a great option, and we can share this package along our company, open source, what ever.
Creating the SPM Package
As this package will not fit under Modules
of our app because it really is a dependency, we will create another folder called Libraries
to know that those packages are shared. Then on Library/Promises folder we execute swift package init --type library
. We have our package ready. (commit: ed82c10
)
Note: like this package will be only one target and for iOS, I’ll do a little cleanup. Note: SPM allow us to share our packages to other platforms, following this kind of settups will make us able to reuse a lot of components
I’ve removed the test for linux, and set that this library only build for iOS with minimum version iOS 10. Also moved the source code to Sources
instead Sources/Promises
. (commit: ca06faa
)
Okey, we have the package, but we need set it as dependency and we able to work on it, more now that the package it’s under initial development.
In order to do this, we need to add the package dependency to our Support
Module, due that this Module it’s available to the whole app, and we want Promises
also available to the whole app.
Also, to have the SPM under development, we only have to add it to our Workspace (doc). (tuist do this automatically when use the path
Package initializer) (commit: 0af3a05
)
Start to creating our Promises
Promises are objects that will help us doing asynchronous tasks, with fixed Types and allow us to mutating the given data by then
& catch
methods. Its very similar to the used method on the project Done<Value, E: Error> = (Result<Value, Error>) -> Void
but with more syntax sugar, and easy to extend but not to modify (Open Close principle).
The result approach it’s fine, but learned the promises, learned a the basis of FPP.
Even using Result
if we do this:
public typealias CharactersCompletion = (_ result: Result<[Character], CharacterRepositoryError>) -> Void
We’re adding extra complexity, we are creating more typealias
for each type of response on our Repository
layer, and this is creating more code to maintain, less standard solutions… since we can have only one Completion
declared on support:
public typealias Done<T, E: Error> = (Result<T, E>) -> Void
The first thing that help me a lot about FPP it’s read a function with the following sentence, a function it’s something that GIVEN some parameters, DO some work and RETURNS something. This is extreamly useful since we can have Objects, with function as properties, intializer parameters and combining this features with generics will make our code work easier.
Since our promises will execute some work, we need to add them the hability to choice where to perform that work. This will be expressed by a protocol called Queue
. (commit: fd69d7e
)
protocol Queue {
func execute(_ work: @escaping () -> Void)
}
And our Promises will have an State
, since they could be pending
, fullfilled
(completed) or rejected
(completed with error).
So lets create an enum State
with this possible states.
We end up with this: (commit: 6efbaa7
)
enum State<Value, E: Error> {
case pending()
case fulfilled(value: Value)
case rejected(error: E)
var isPending: Bool {
if case .pending = self {
return true
} else {
return false
}
}
var isFulfilled: Bool {
if case .fulfilled = self {
return true
} else {
return false
}
}
var isRejected: Bool {
if case .rejected = self {
return true
} else {
return false
}
}
}
Now we’re going to create the promise object, this object must be initializated with a work to perform, and a Queue
where perform it. But we need a way to nofify the Promise
when the work it’s done. We’re going to do this creating a new object called Future
, this object has two blocks, one to fullfill the Promise
, and another to reject it. (commit: 0ea74a8
)
public struct Future<T, E: Error> {
public let fullfill: (T) -> Void
public let reject: (E) -> Void
}
With the need of a place to store the callbacks that will be executed when the promise ends, a good choice it’s to keep them on the State.pending
, so we need to add a place that stores the work to perform when the promise completes.
This work have two options fullfill and reject, since the promise can be completed with success or error. This fits perfectly with the Future object we’ve created, so let’s add the Future into the State.pending
, (because we only need to keep the blocks if the promise it’s not completed)
We need that the pending state can be initialized with an empty future, so instead of a Future, let’s add an Array of Futures. (commit: 1288f38
)
enum State<Value, E: Error> {
case pending(future: [Future<Value, E>])
case fulfilled(value: Value)
case rejected(error: E)
}
Now that we have all those components, let start to creating our Promise
. The first thing that we need it’s an empty initialzer, and remember, Promise
must has an State
, and how we will mutate the promise, but we don’t want that it changes his reference, it must be a class (final to be closed to modification) (commit: 0515e26
).
public final class Promise<T, E: Error> {
private var state: State<T, E>
public init() {
self.state = .pending(futures: [])
}
}
Now we must be able to create the promise with a work that when ends launch the fulfill or the reject method. So let’s add it. (commit: a7cfcc3
)
public final class Promise<T, E: Error> {
private var state: State<T, E>
public init() {
self.state = .pending(futures: [])
}
public convenience init(on queue: Queue, _ work: @escaping (Future<T, E>) -> Void) {
self.init()
queue.execute {
work(Future(fulfill: self.fulfill, reject: self.reject))
}
}
private func fulfill(with value: T) {
}
private func reject(with error: E) {
}
}
And we will need the option to create a Promise
with Error or Value, so let’s add those initializer methods. (commit: 06e592c
)
Now we are going to do the hard part. Lets make our promises work, how them work? Adding futures to the pending state. Thats all. Seriously. Thats all.
private func addFuture(future: Future<T, E>) {
switch state {
case .pending(let futures):
state = .pending(futures: futures + [future])
case .fulfilled(let value):
future.fulfill(value)
case .rejected(let error):
future.reject(error)
}
}
That it’s the most important method on the Promise
. Let’s explain all steps on that method to be clear. If the promise is pending (work it’s currently being executed), we take the prevous futures and add the new one, but we keep .pending
.
If the promise has done the work, and there is a value, we execute the given future.fulfill passing to it the value.
And the last, if has done the work with an error, we call the rejected with the given error. (commit: 981cd16
)
We still have empty functions on Promise.fulfill
and Promise.reject
so lets fill them.
private func fulfill(with value: T) {
guard case .pending(let futures) = state else { return }
state = .fulfilled(value: value)
futures.forEach { $0.fulfill(value) }
}
private func reject(with error: E) {
guard case .pending(let futures) = state else { return }
state = .rejected(error: error)
futures.forEach { $0.reject(error) }
}
This methods can be only called one time, the thime that the work ends, then they mutate the state to fulfilled
or rejected
, and then the rest of thens
and catch
will be instantly executed because the promise has done the job. (commit: e858fd4
)
Now that we understand how a promise works, we need to make the state mutation thread safe. In order to do this, the only thing we need it’s a private queue and make the state mutations inside that queue.
Finally Promise looks like: (commit: d660548
)
public final class Promise<T, E: Error> {
private var state: State<T, E>
private let lockQueue = DispatchQueue(label: "promise.lock.queue", qos: .userInitiated)
public init() {
self.state = .pending(futures: [])
}
public init(value: T) {
self.state = .fulfilled(value: value)
}
public init(error: E) {
self.state = .rejected(error: error)
}
public convenience init(on queue: Queue, _ work: @escaping (Future<T, E>) -> Void) {
self.init()
queue.execute {
work(Future(fulfill: self.fulfill, reject: self.reject))
}
}
private func fulfill(with value: T) {
lockQueue.async {
guard case .pending(let futures) = self.state else { return }
self.state = .fulfilled(value: value)
futures.forEach { $0.fulfill(value) }
}
}
private func reject(with error: E) {
lockQueue.async {
guard case .pending(let futures) = self.state else { return }
self.state = .rejected(error: error)
futures.forEach { $0.reject(error) }
}
}
private func addFuture(future: Future<T, E>) {
lockQueue.async {
switch self.state {
case .pending(let futures):
self.state = .pending(futures: futures + [future])
case .fulfilled(let value):
future.fulfill(value)
case .rejected(let error):
future.reject(error)
}
}
}
}
At this point that we’re going to start using the Queue protocol, we’re going to extend DisptchQueue
to conform the protocol and add it as default parameter on the Promise.init(queue, work)
.
extension DispatchQueue: Queue {
func execute(_ work: @escaping () -> Void) {
async { work() }
}
}
public convenience init(on queue: Queue = DispatchQueue.global(qos: .userInitiated), _ work: @escaping (Future<T, E>) -> Void) {
self.init()
queue.execute {
work(Future(fulfill: self.fulfill, reject: self.reject))
}
}
Now we are going to add the then
method, this method will set (or call if finished) fulfill and reject methods. (commit: a7279a0
)
@discardableResult
public func then(on queue: Queue = DispatchQueue.main, _ fulfill: @escaping (Value) -> Void, _ reject: @escaping (Error) -> Void) -> Promise<T, E> {
addFuture(future: Future(fulfill: fulfill, reject: reject))
return self
}
But as we can see, then
method expects to execute on an specified queue. This means that we need to update our Future
object to perform his clousures on that queue.
public struct Future<T, E: Error> {
public typealias Fulfill = (T) -> Void
public typealias Reject = (E) -> Void
public let fulfill: Fulfill
public let reject: Reject
private let queue: Queue?
init(on queue: Queue? = nil, fulfill: @escaping Fulfill, reject: @escaping Reject) {
self.queue = queue
self.fulfill = fulfill
self.reject = reject
}
func callFulfill(value: T, _ completion: @escaping () -> Void = {}) {
assert(queue != nil, "callFulfill only can be called if queue is not nil")
queue!.execute {
self.fulfill(value)
completion()
}
}
func callReject(error: E, _ completion: @escaping () -> Void = {}) {
assert(queue != nil, "callReject only can be called if queue is not nil")
queue!.execute {
self.reject(error)
completion()
}
}
}
To avoid this complex Future
struct and merge two functionalities on the same object, (pass to the user to complete the promise, and add it as callback to the pending state) we’re going to create a new struct Callback
to handle this and let the Future only to the user.
You can check the end of this changes on fe12512
.
Now we must add a function that mutate both values inside our promise, the error to a new error and the Value to a new Value.
This function, adds a Callback
to our Promise
, that will execute the given blocks on the given queue when the promise ends with the promise result, and create a new Promise
with the result of that Callbacks
. So the mutation happens inside the maps.
Like we are typing the error, our mapValue if throws
must throw
always the new promise type error, and the mapp error is needed for mutate from the first Promise
error to the new Promise
error (commit: 0a257c5
).
Also I’ve marked as private the old then
. (I’ll why explain a little bit later).
@discardableResult
public func map<NewValue, NewError: Error>(on queue: Queue = DispatchQueue.main,
_ mapValue: @escaping (T) throws -> Promise<NewValue, NewError>,
_ mapError: @escaping (E) -> NewError) -> Promise<NewValue, NewError> {
Promise<NewValue, NewError> { future in
self.then(
on: queue,
fulfill: { oldValue in
do {
try mapValue(oldValue).then(on: queue, fulfill: future.fulfill, reject: future.reject)
} catch let error {
future.reject(error as! NewError)
}
},
reject: { oldError in
future.reject(mapError(oldError))
}
)
}
}
With that map done, to map Values and Errors, now we can create the then
to map only the value. This function it’s pretty easy. We only need to use the last function, but only updates de Value
type. (commit: 10731ba
)
@discardableResult
public func then<NewValue>(on queue: Queue = DispatchQueue.main,
_ mapValue: @escaping (T) throws -> NewValue) -> Promise<NewValue, E> {
map(
on: queue,
mapValue: { Promise<NewValue, E>(value: try mapValue($0)) },
mapError: { Promise<NewValue, E>(error: $0) }
)
}
And then, the same but for errors. (commit: 92e8a7d
)
@discardableResult
public func `catch`<NewError: Error>(on queue: Queue = DispatchQueue.main,
_ mapError: @escaping (E) -> NewError) -> Promise<T, NewError> {
map(
on: queue,
mapValue: { Promise<T, NewError>(value: $0) },
mapError: { Promise<T, NewError>(error: mapError($0)) }
)
}
Once we have all this functions inside the Promise, it’s time to check that it works, and to do this, we can simply add a couple of tests.
At this time, I was testing the .catch
and to only catch without mutate the Error type, I’ve needed to extend the Promise and add a new catch method.
Also I’ve done a little refactor, creating two new files, Then.swift
and Catch.swift
where I extracted those method, to take some code out of how the promise works, due that really those methods are syntactic suggar for what we already have.
Finally, you can go to this commit and check the test passing (commit: 166f7bb
).
Conclusion
We created from the scratch a very simple Promise
framework that allow us to mutate values and perform asynchronous task.
We combined enums
with structs
, we worked with functional concepts like Monads.
If we understand how this Promise
works, we will understand easier new concepts like subscribers, new SwiftUI @ObservedObjects
, swift Combine
, and we can adopt a less code way, with less options to write bugs.
Notice that this Promise Framework its highly inspided on Khanlou Promise frameworks, but allowing us to type the Error.
Typing the error limit us on what erros can be thrown, and since Swift doesn’t allow us to type errors under throwing functions, if we throw a not expected error type inside map or then, these promises will crash on runtime. But typing erros we will have the option of switch
over a known error.
To avoid this, you can do remove the error typed on the promise, or use one of the most popular frameworks named on the start of the article that doesn’t allow to type the promise.
I encourage you to test the framework, start to extend it and understand how it works closely. Maybe not promises, but FPP combined with OOP like nowadays we can do, it’s the future of how the apps will be made, and we need know it.
Steps
- Introduction
- Xcode at Scale
- Refactor MarvelClient - split client from logic
- Refactor DataProvidersKit - applying Iversion of Control
- Refactor DetailKit - Single data flow, states, type erasure and more
- Refactor Navigator - Back to simplest
- Refactor AppCore - we really need it?
- Dependency Injection - flexible and composable
- Extra ball: Cache made easy
- Extra ball: Introducing to Promises 1
- Extra ball: Introducing to Promises 2
- Final conclusions