The JavaScript ecosystem evolves at a breakneck pace. Just as you get comfortable with a certain technique, a slew of new methodologies emerges. Some, like TypeScript, gain widespread adoption, while others, such as CoffeeScript, quietly fade away. Each innovation initially stirs excitement, but over time, the community often splits, with detractors eventually spawning their own frameworks. This endless cycle has made me increasingly wary of the latest "magic" frameworks purported to solve all problems. I've shifted from seeking tools as solutions to embracing the understanding of patterns over the constant chase for new technology.
This is why I'm pointing you towards something special for your TypeScript projects, not just another tool but a paradigm that encourages good practices: Effect.
Let's take a look at why you should take the leap.
Colored functions
Have you ever asked yourself what color is your function?
Let me summarize it for you. Imagine you have blue and red functions in your code base. The rule is simple: you can use red functions inside your blue functions but not the other way. Wouldn't that be a nightmare? Now replace blue by "async". Yep, you got function coloring in Javascript.
Wait, I could just put "async/await" everywhere and no more problems.
Yes, more problems. I won't enter the details but the way Promise work in Javascript since it's single threaded is with a stacking queue so just by adding "async" you will actually make your program really slow.
const then = Promise.prototype.then;
let n = 0;
Promise.prototype.then = function (onFulfilled, onRejected) {
n++;
return then.apply(this, [onFulfilled, onRejected]);
};
const fibonacci = async (a: number): Promise<number> => {
return a <= 1 ? a : (await fibonacci(a - 1)) + (await fibonacci(a - 2));
};
await fibonacci(10);
console.log(n); // n = 55
Congratulations, you just added 55 ticks to your event loop and made your simple program way slower.
So how do we fight this coloring thing? If we want to remove colored functions, we would need to create some sort of wrapper that will use a Promise only when needed. Like "Future" or... "Effect"?
import { Effect, pipe } from "effect";
const then = Promise.prototype.then;
let n = 0;
Promise.prototype.then = function (onFulfilled, onRejected) {
n++;
return then.apply(this, [onFulfilled, onRejected]);
};
const fibonacci = (a: number): Effect.Effect<number> =>
a <= 1
? Effect.succeed(a)
: pipe(
Effect.all([fibonacci(a - 1), fibonacci(a - 2)]),
Effect.map(([a, b]) => a + b)
);
await Effect.runPromise(fibonacci(10));
console.log(n); // n = 1
mic drop
Typesafe errors
Let's look at this function:
const divide = (a: number, b: number) => a / b;
We just introduced a problem here, we cannot divide by zero. So let's refactor the code a little bit:
const divide = (a: number, b: number) => {
if (b === 0) throw new Error('Cannot divide by zero.');
return a / b;
}
Looks good to you? It is not. Because it is not typesafe. Someone that will want to use your function won't have any idea that your function can throw. This might look trivial with a simple function like this one but when you have dozens of potential errors, it can become a nightmare. Other more mature languages have notions such as Either
or Result
to have typesafe errors. It looks like that:
type Result<T, E> = { kind: "Ok", data: T } | { kind: "Err", err: E }
With Effect, you will have that out of the box: Effect<T, E>
. You won't ever have to ask yourself what kind of errors can occur during the run, you can know it directly from the function signature. It also comes with helper functions to recover from errors.
Newtypes or branded types
You know, looking back at my previous function I realize we could do better.
const divide = (a: number, b: NonZeroNumber) => ...
How do you define NonZeroNumber
though? If you just do type NonZeroNumber = number
it won't prevent people to call it with "0". There is a pattern for that: newtypes. And yeah, Effect supports that too:
import { Brand } from "effect"
type NonZeroNumber = number & Brand.Brand<"NonZeroNumber">
const Int = NonZeroNumber.refined<Int>(
(n) => n !== 0, // Check if the value is a non-zero number
(n) => Brand.error(`Expected ${n} to be a non-zero number`)
)
This way, you know your function cannot be called with any number: it expects a special type of number which exclude zero.
Dependency injection
If you want to follow the "Inversion of Control" principle, you might want to look into "Dependency Injection". This concept is really simple: a function should have access to what it needs from its own context.
// With a singleton
const read = (filename) => FileReader.read(filename);
// With dependency injection
const read = (reader: FileReader) => (filename) => reader.read(filename);
It's better to do it this way for many reasons such as uncoupling things, allowing for easy testing, having different contexts etc.
While several frameworks assist with this, Effect really crushed it by making it straightforward: put your dependencies as the third parameter of an Effect.
const read = (filename): Effect<File, Error, FileReader> => ...
Conclusion
There are many other reasons why you should consider Effect. For sure, it's not going to be easy at first, you will have to learn to code differently. But contrary to many frameworks that make you learn "their" way of doing something, Effect actually teaches you good patterns that have made their proofs in other languages. Actually, Effect is heavily inspired by ZIO from Scala which itself has been inspired by Haskell which is still today considered as one of the pinacle of good programming patterns.