Since the beginning of the year, I’ve been using a new TypeScript library called Effect on personal and work projects at doubleloop.io.
Effect solves common problems we face every day in software development, such as concurrency, composability, and making things explicit with the power of TypeScript type system.
Effect allows to build composable applications with a strong focus on type safety and error handling.
Yes, this is quite a generic and broad definition.
I like that this library provides you all the building blocks to create your application without compromising on anything important.
The image below summarize some of the core features part of the ecosystem:
On this article I’m going to focus on Concurrency, Dependency Injection and a little bit of Observability.
We’re going to rebuild the main workflow of my personal project football-calendar.
I created this application to sync google calendar with football matches of my favorite team to solve a problem I was facing using different football mobile apps.
I retrieve football matches from api-football.com and I use Google Calendar as database and front end to display football matches as events.
The workflow of the app is:
Retrieve football matches from api-football.com and calendar events from Google Calendar
Elaborate those data and produce a list of commands (for example, to create a new calendar event or update an existing one)
Execute the commands to update the database
Let’s start from an empty function, footballMatchEventsHandler, that given a teamId will execute the workflow of the app:
What should be the return type of this function? The Effect<A, E, R> type!
Effect<A, E, R> type is lazy and immutable description of a workflow.
A or Value type parameter: the value of the succeeded Effect
E or Error type parameter: the expected errors that this Effect may produce. Default is never
R or Requirements type parameter: the dependencies that the Effect needs to be executed. Default is never
We can say that footballMatchEventsHandler function returns a Effect<void> which means that it doesn’t return any value (A = void), doesn’t produce any expected error (E = never) and has no requirements (R = never).
Let’s start by retrieving the football matches. We need to pass a loadMatchesByTeam function to footballMatchEventsHandler and for this we can use the Requirements of the Effect type.
We need to create a type or interface where we can define all requirements.
Then we need to create a representation of Deps at value-level using a Tag:
Now that we have defined the Deps type, let’s change the return type of footballMatchEventsHandler
To use the requirements we have to start our pipeline (in alternative you can use generators) to extract and invoke loadMatchesByTeam:
If we run the application, we can see that loadMatchesByTeam is executed!
I’m using a custom logger to have
structured logs in json
This should be easier, we know what we have to do.
First we add loadCalendarEventsByTeam function to Deps.
Then to always have the requirements in scope we have to create a second pipeline.
We are already invoking loadMatchesByTeam but we also want to invoke loadCalendarEventsByTeam.
How can we combine many effects into one? Using Effect.all.
In TypeScript you would have used Promise.all, but with Effect.all you gain proper type safe error handling and more interesting behaviors we’ll see in the next section.
Now if we run the application, both football matches and calendar events are retrieved.
If you have a closer look at the order of the logs (and the timestamps) we can see that they are executed sequentially.
This is the default behavior of every effect.
Even though this two effects are quite fast, we can easily experiment and try to run them concurrently.
Effect.all accepts a second parameter where we can specify the concurrency, in this case we only have two effects so we can set it to 2.
From now onwards, the code snippets are going to focus on the second pipeline.
And now the output changes as both effects starts concurrently:
We’re almost done. This is the last part and our application will be completed!
We have to execute the commands to update the database (Google Calendar).
We don’t need to do anything for commands of type NothingChanged, so let’s filter them out of the list.
To create or update calendar events we need two new requirements: createCalendarEvent and updateCalendarEvent.
What is left to do now is to invoke createCalendarEvent or updateCalendarEvent based on the command type.
Here comes handy the Match module that allows to do exhaustive pattern matching (like ts-pattern, but deeply integrated into the Effect ecosystem and conventions).
And inside footballMatchEventsHandler we can use it like this:
Finally we can see that we are creating new calendar events!
The whole process to create 31 calendar events took 40 seconds. Let’s see if we can improve it using concurrency like before.
Until now we always specified a fixed number of concurrent effects, but we can also use unbounded to run as many effects as possible concurrently. Let’s try it!
Let’s run the application and see how much time it takes.
Oh no, we broke the application! As you can see from the error message the Google Calendar API responded with a “Rate Limit Exceeded” because we were doing too many requests concurrently.
We can say that using concurrency unbounded can be a double-edged sword and we have to be careful when to use it.
We can fix this issue in two way:
Using controlled concurrency
Using the Scheduling module, retrying to insert/update a calendar event following a policy we define (for example with exponential backoff when we exceed the rate limit)
Since this application is simple I chose option 1, as having 5 concurrent effects is absolutely fine for this scenario.
We made it! We re-implemented the whole workflow of my app 🎉!
I’m sure you still have questions/doubts about what we did, for example how do I provide the implementation of the requirements? How do you actually execute an Effect? And many more…
I’ll try to answer some of those questions by writing a test.
To run an Effect we can use:
Effect.runPromise returns a Promise that may reject if the Effect produce an error
Effect.runPromiseExit return always a fulfilled Promise with the Exit type, which is a union of Success<A, E> | Failure<A, E>
We can write a test to check that a calendar event is created when there’s a new football match:
There’s something wrong, we got a type error at Effect.runPromiseExit!
Do you remember earlier we sad that the Effect returned by footballMatchEventsHandler needs requirements (Deps) to be executed? This error warns us about that!
We need to provide an implementation of Depsand then we can call Effect.runPromiseExit.
In alternative, for more complex scenarios you should use
layers
I modified the code we wrote in this article to make it simpler to understand and remove domain implementation details.
You can find maintained and up-to-date code in the main branch inside devmatteini/football-calendar repository. If you want to explore more, this is the right place to start!
We started exploring Effect and there is a lot more to see and try.
What we just did is a very common workflow: retrieve data from external sources, do some business logic and save the results to a database.
What I like about Effect is that you can experiment, iterate and refactor with confidence that your application won’t break.
Effect leverage the type system to make things explicit: errors and requirements. Although we only covered the requirements part, you should check out error handling.
The learning curve may seem high at first, but from my experience I can say that it was worth it because I enjoy writing TypeScript with Effect much more.