beginner
In Functional Programming, functions must honor its mathematical definition: they must be total, deterministic and pure. However, this is not the situation in many cases; in fact, programs are useful when side-effects happen. The issue is not having side-effects per se, but the moment and the circumstances when they are executed. If they happen in the moment we invoke them, reasoning about their outcome becomes much more difficult. In addition to that, composition becomes harder, if not impossible, to achieve.
Consider the following two functions:
func greet(name: String) {
print("Hello \(name)!")
}
func homePage(callback: @escaping (Either<Error, Data>) -> ()) {
if let url = URL(string: "https://bow-swift.io") {
URLSession.shared.dataTask(with: url) { data, _, error in
if let data = data {
callback(.right(data))
} else if let error = error {
callback(.left(error))
}
}.resume()
}
}
Function greet
has a side effect (printing to the console) and does not produce any value, making it harder to compose this function with any other function. Function homePage
does produce values, but they are returned through a callback as it is an asynchronous operation, making it difficult to be composed as well, besides having another side effect (network call).
Dealing with this type of functions is usual in our programming routine, either because we have some non-functional legacy code or because we are using libraries that do not provide a functional API. What can we do to overcome this issue?
Bow Effects provides the IO data type. It is a data type that lets us suspend the execution of side effects, providing us a value that describes the side effect, but it has not been performed yet. That way, we can convert our programs into values that we can combine and compose.
IO
has a method called invoke
that can help us suspend a side effect. For instance, the greet
function above can be rewritten as:
func greetIO(name: String) -> IO<Never, Void> {
return IO.invoke { greet(name: name) }
}
It may seem like we haven’t done much, as we are still calling the old function, but its execution is deferred. It has been wrapped in an IO
value that cannot produce errors (the Never
type) and that do not produce any value (the Void
type). Function greetIO
is pure and provides a value as its output that can be composed with other IO
values to make a bigger program.
We can also wrap functions throwing errors in an IO using invoke
. The function findUser(by:)
below is impure:
func findUser(by id: String) throws -> User {
guard exists(id: id) else {
throw DatabaseError.missing(id: id)
}
return fetch(id: id)
}
Its pure counterpart can be written as:
// Making use of the impure version
func findUserIO(by id: String) -> IO<DatabaseError, User> {
return IO.invoke { try findUser(by: id) }
}
// Writing a new pure version
func findUser_fromEither(by id: String) -> IO<DatabaseError, User> {
return IO.invokeEither {
guard exists(id: id) else {
return .left(.missing(id: id))
}
return .right(fetch(id: id))
}
}
The functional versions above have an additional benefit: they are explicit about the type of the errors that may happen during their execution.
Besides invoke
, IO
has other methods to create a suspended side effect, like the invokeEither
above, invokeResult
, invokeValidated
and invokeTry
.
Let’s look now at the homePage
function. It provides a generic (non-typed) error or a Data
value, but it does it through a callback. How can an asynchronous call be suspended into an IO
value?
IO
has a method called async
that serves to this purpose:
func homePageIO() -> IO<Error, Data> {
return IO.async { callback in
if let url = URL(string: "https://bow-swift.io") {
URLSession.shared.dataTask(with: url) { data, _, error in
if let data = data {
callback(.right(data))
} else if let error = error {
callback(.left(error))
}
}.resume()
}
}^
}
This new function provides an IO<Error, Data>
value that suspend this asynchronous call and that can be transformed or composed.
intermediate
So far we have seen how to create an IO
suspending a side effect. There are other cases where we may be interested on creating an IO
that depends on a context that we still do not have.
To this purpose, Bow Effects provides the EnvIO<D, E, A>
type, which models an IO<E, A>
that depends on some context D
. This type is useful to have a sort of dependency injection, where we can write our programs without worrying too much about where the context is coming from, and later, when we can provide such context, we run the program.
For instance, consider the following services: a network API to fetch users by their id and a database to save the user.
protocol API {
func getUser(by id: String) -> IO<Error, User>
// ... Other methods ...
}
protocol Database {
func save(user: User) -> IO<Error, Void>
// ... Other methods ...
}
We can create an Environment that provides these abstractions:
struct Environment {
let database: Database
let api: API
}
Now, let’s say that we want to implement a function that gets a user from the API and stores it in the database. We can create an EnvIO
that depends on the Environment
that we created above to get the API
and Database
:
func cacheUser(by id: String) -> EnvIO<Environment, Error, ()> {
return EnvIO { environment in
environment.api.getUser(by: id)
.flatMap { user in environment.database.save(user: user) }
}
}
This will allow us to run the program that we have created under different environments; e.g. we can provide different implementations for production and testing:
// Providing production implementations
let prodEnv = Environment(database: ProductionDatabase(),
api: ProductionAPI())
cacheUser(by: "12345").provide(prodEnv)
// Providing testing implementations
let testEnv = Environment(database: TestDatabase(),
api: TestAPI())
cacheUser(by: "12345").provide(testEnv)