Table of contents
Introduction
Initially, I wanted to talk about function purity and referential transparency at this point of the series. However, when preparing the article, I used a significant amount of references to the term "side effect". This is why I chose to introduce side effects before talking about function purity. Both concepts are related to one another, but they deserve their separate article.
So, what is a side effect? A side effect is an interaction with the outside world.
Now you might be wondering, what is the "outside world" then? It is anything that is not part of the domain layer of the software. Here are some examples of these types of interactions:
- Reading a file, and/or writing to a file
- Making a network request (calling an API, downloading a file...)
- Reading from a global state (e.g. global variable, a parameter from the parent's closure...)
- Throwing/intercepting an exception
- For web applications, using DOM objects and methods
- Logging messages to the console
The domain layer contains all the models, logic, and rules of the domain. If you are familiar with the "hexagonal architecture", also known as "ports and adapters architecture", then this layer is at the very center of the diagram.
Generally speaking, all the functions from the domain layer can be tested with unit tests, as there are no side effects. This is the only layer that is pure, all the others encompassing it have side effects.
In functional programs, functions have to be pure, i.e. side-effect-free. Why? Because side effects are not deterministic, and they are hard to reason about.
When we want to validate that a function works as expected, we have to be able to understand it and use automated tests. Introducing non-determinism and external references into the function makes it hard to understand it simply, and test its behavior.
That being said, a program that has no side effects is not very useful. We need interactions with the outside world, such as getting input from a user, writing to a database, or displaying something to the screen.
So, on the one hand, side effects are bad, but on the other hand, they are mandatory to build useful programs. What can we do about it?
How to deal with side effects
One particularity of a side effect is that it just happens. In our imperative programs, a side effect does not return anything. In other words, we cannot store it in a variable, or pass it as a parameter of a function.
We can easily identify functions that perform side effects, as they are the ones that do not return any value. For example:
function trustMeNoSideEffectHere(): void {
...
}
This function's name is misleading as its return type does not match its name. The void
type indicates that this function does not return any value. Therefore, if it does not compute any value, it is either useless or performs at least a side effect.
Of course, we can still have functions that return a value, but also perform side effects:
function trustMeNoSideEffectHere(): number {
let n = 21
window.foo.bar++ // mutating the global state = side effect
return n * 2
}
In an imperative program with no defined rules regarding side effects, we must read the definition of the function to understand what it does, thus losing the type-based reasoning benefit.
Furthermore, the moment we call the trustMeNoSideEffectHere
function, the side effect immediately happens. We have to make sure the environment is ready right before calling the function, otherwise, we are exposed to undesired behaviors.
In functional programs, since functions are pure (i.e. no side effects), they must return a value, otherwise, they are pretty much useless. In addition, determinism is a key aspect of functional programming. We cannot allow a function to trigger a side effect just by calling it. As developers, we want to control when the side effect happens (remember, side effects must happen at some point, otherwise the program is pointless).
How can we make the trustMeNoSideEffectHere
function pure, and control when the side effect (i.e. global state mutation) happens?
One way of doing it would be to wrap some parts of this function in another function, making it "lazy":
function trustMeNoSideEffectHere(): () => number {
let n = 21
return () => {
window.foo.bar++ // mutating the global state = side effect
return n * 2
}
}
What happens when we call trustMeNoSideEffectHere()
? We get a value of type () => number
, that we can store in a variable:
const res: () => number = trustMeNoSideEffectHere()
Did anything happen? No. The global state has not been mutated yet since we have not called res()
yet. Though, we changed the definition of trustMeNoSideEffectHere
to make it pure: it always returns the same value, a function.
Granted, if we call the function twice, we will get different references for the inner function: trustMeNoSideEffectHere() === trustMeNoSideEffectHere()
would be false. So technically, we do not return the "same" value. Still, since functions equality is a complex topic, let us assume that the values are equivalent here, and move on.
Now that the function is deterministic, we can write a unit test, although it is not very useful in this case:
expect(trustMeNoSideEffectHere()).to.be.a('function')
Imagine you are testing the
launchNuclearWarheads
function. It is pretty nice to make sure that callinglaunchNuclearWarheads()
does not do anything, and that it requires an extra step to actually end life on Earth.
The moment we are calling res()
, the side effect will happen, thus propagating the non-determinism to the parent functions.
At this point, it is not possible to write unit tests for the main
, serviceA
, and serviceB
functions. What can we do to make these functions deterministic? We can apply the same strategy as before: returning functions that wrap the side effect (cf. the blue wrapping function on the following picture).
We managed to make the serviceA
and serviceB
functions side-effect-free. Only the main
function is impure and performs side effects, which is convenient: now we know where all the side effects actually happen in the program.
Chaining side effects
What happens when we want to chain side effects? For example:
- First we want to ask for the user's name
- Then we want to perform some API call with the user's name (e.g. to retrieve some information regarding its origin)
- Finally we want to display the result on the screen
Again, we can use the same technique: make any side effect lazy by wrapping it inside a function.
(here we are assuming the code is run in a browser)
function askForUserName(): () => string {
return () => prompt('What is your name?')
}
function getUserNameOrigin(userName: string): () => Promise<string> {
const sanitizedName = userName.toLowerCase()
return () => fetch(`/get-origin/${sanitizedName}`)
.then(res => res.json())
.then(res => res.userNameOriginText)
}
function displayResult(text: string): () => void {
return () => {
document.getElementById('origin-text')?.innerText = text
}
}
function originNameService(): () => Promise<void> {
return () => {
const lazyUserName = askForUserName()
const lazyGetUserNameOrigin = getUserNameOrigin(lazyUserName())
return lazyGetUserNameOrigin()
.then(originText => {
const lazyDisplayResult = displayResult(originText)
lazyDisplayResult()
})
}
}
function main(): void {
const lazyOriginNameService = originNameService()
lazyOriginNameService()
}
Here, only the main
function is non-deterministic, the remaining functions are all side-effect-free, thanks to the wrapper functions.
That being said, writing tests for these pure functions would not be very useful, as we would not test their actual behavior:
expect(askForUserName()).to.be.a('function')
expect(getUserNameOrigin('foo')).to.be.a('function')
expect(displayResult('bar')).to.be.a('function')
To overcome this, we could use Dependency Injection, a.k.a DI. This is a bit out-of-scope for this article, but essentially we would pass the actual effect (i.e. prompt
, fetch
...) as a dependency of the function, and we would use a mocked/controlled version of this effect for writing the unit tests.
For example:
interface Dependencies {
prompt: (text: string) => string
}
function askForUserName({ prompt }: Dependencies): () => string {
return () => prompt('What is your name?')
}
const lazyUserName: () => string =
askForUserName({ prompt: window.prompt })
const fakePrompt = (text: string) => 'foo'
const lazyUserName: () => string =
askForUserName({ prompt: fakePrompt })
expect(lazyUserName).to.be.a('function')
expect(lazyUserName()).to.equal('foo')
What if we wrote a simple type alias for the introduction of laziness?
type IO<A> = () => A
function askForUserName(): IO<string> { ... }
function getUserNameOrigin(userName: string): IO<Promise<string>> { ... }
function displayResult(text: string): IO<void> { ... }
function originNameService(): IO<Promise<void>> { ... }
function main(): void { ... }
And what about a special alias for the lazy promises?
type Task<A> = IO<Promise<A>>
function askForUserName(): IO<string> { ... }
function getUserNameOrigin(userName: string): Task<string> { ... }
function displayResult(text: string): IO<void> { ... }
function originNameService(): Task<void> { ... }
function main(): void { ... }
The type of main
is () => void
, so in other words, main: IO<void>
. Also, askForUserName
and originNameService
do not take any argument, so they are already lazy. We can simplify their definition with:
const askForUserName: IO<string> = ...
const originNameService: Task<void> = ...
const main: IO<void> = ...
Now imagine if these IO
and Task
types had some behavior attached to them, allowing us to do something such as:
const originNameService: Task<void> =
Task.fromIO(askForUserName)
.andThen(getUserNameOrigin)
.andThen(text => Task.fromIO(displayResult(text)))
This is how we can contain side effect declarations inside values, that we can compose with other values, thus describing more and more complex side effects. To run the side effects, we can simply run the final value obtained from the composition:
// assuming we have some behavior attached to our Task<void> value
originNameService.runEffect()
Here, I presented one approach we can use to handle side effects.
We have separated their declaration from their execution, giving us more flexibility to combine these effects, and more control over what happens in our program.
Functional languages and libraries provide "standard" tools to deal with side effects, so we do not have to build them ourselves. Haskell has a built-in IO
data type. The fp-ts TypeScript library provides both IO
for synchronous side effects, and Task
for asynchronous ones. The cats-effect Scala library provides the IO
data type as well.
Writing functional programs will undoubtedly lead you to use these data types to deal with side effects.
The goal is to have as many side-effect-free functions as possible in our programs, by leveraging side effects declarations, and isolating side effects execution.
This concludes the chapter on side effects. Hopefully, you enjoyed it and understood what side effects are, and how you can deal with them.
The next step is to talk about function purity and referential transparency. As always, feel free to leave a comment :)
See you then!
Special thanks to Tristan Sallé for reviewing the draft of this article.
Photo by Xavi Cabrera on Unsplash.
Pictures made with Excalidraw.
Top comments (0)