DEV Community

Cover image for Side effects
Benoit Ruiz
Benoit Ruiz

Posted on

Side effects

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.

Domain layer at the center, encompassed by the Application layer which "side effect" interactions with outside elements

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 {
  ...
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

What happens when we call trustMeNoSideEffectHere()? We get a value of type () => number, that we can store in a variable:

const res: () => number = trustMeNoSideEffectHere()
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

Imagine you are testing the launchNuclearWarheads function. It is pretty nice to make sure that calling launchNuclearWarheads() 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.

All the parent functions of the "res" function are made non-deterministic as soon as "res" is called

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).

By returning wrapper functions (containing the "res" function call) up the chain, only the top parent function (main) is made non-deterministic when finally calling the wrapper function

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()
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode
const fakePrompt = (text: string) => 'foo'

const lazyUserName: () => string =
  askForUserName({ prompt: fakePrompt })

expect(lazyUserName).to.be.a('function')
expect(lazyUserName()).to.equal('foo')
Enter fullscreen mode Exit fullscreen mode

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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 { ... }
Enter fullscreen mode Exit fullscreen mode

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> = ...
Enter fullscreen mode Exit fullscreen mode

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)))
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)