Learn about a new paradigm to implement software applications at scale, using the type system to drive development:
- Return errors as values and collect them on the return type
- Define and compose services in layers using dependency injection
- Use the compiler to fix errors before releasing the app
This new way of building software is what Effect is all about π
Software is complex
At the beginning it was all javascript, and it was a nightmare:
function makePayment(params) {
/// ...
}
No types, anything can happen, no checks, no help. No way to manage complexity at all.
Some smart folks created types:
function makePayment(params: { amount: number, email: string }): Promise<void> {
/// ...
}
Great, but not so helpful. We barely know the parameters and what the function returns, but nothing else. Not much help in dealing with real-software complexity.
Most programmers are stuck here. No good π€·πΌββοΈ
Functional programming to the rescue (really?)
Some other smart folks (mathematicians) invented functional programming.
function makePayment(params: { amount: number, email: string }): Promise<Either<Error, void>> {
/// ...
}
New ideas and types to scale complexity: Either, Option, Functor, Applicative, Monad...
Wait? What is this? We are no mathematicians, what is a Monad? Or better, who cares?
Unfortunately, the tools and terminology behind "pure" functional programming is scaring programmers away (and they do have a point).
Complexity at scale
The solution is to make the complexity explicit, understandable, and readable, without jargon, all using types:
function makePayment(params: { readonly amount: number, readonly email: string }): Effect<PaymentReceipt, ConnectionError | PaymentError | WrongParametersError, PaymentService | AuthService> {
/// ...
}
The return type defines everything that can happen:
PaymentReceipt
: If the function succeeds we get back all the information about the paymentConnectionError | PaymentError | WrongParametersError
: If the function fails, we know exactly why and where, so we can respond accordinglyPaymentService | AuthService
: A complex system is composed by many moving parts, the type reports all the services required to make a payment
How is this helpful
Types become our real co-pilot:
- Enforce providing all required services
- Enforce handling all errors
- Enforce creating valid response types
All possible issues are managed and propagated by the type system:
function makePayment(params: { readonly amount: number, readonly email: string }): Effect<PaymentReceipt, ConnectionError | PaymentError | WrongParametersError, PaymentService | AuthService> {
/// ...
}
function sendConfirmEmail(params: { readonly email: string }): Effect<void, SendEmailError | ConnectionError | PaymentError | WrongParametersError, PaymentService | AuthService> {
/// This uses `makePayment`, so it can have all the same errors and requires all the same services π
}
There is more π€©
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
A problem of try and catch
"I don't get this, how does this help to scale complexity?"
Back to the origins, what can go wrong in the code below? π
function makePayment(params: { amount: number, email: string }): Promise<void> {
const card = getCreditCard(params.email);
const isCardValid = getIsCardValid(card);
if (isCardValid) {
return performPayment(card);
} else {
throw new Error("Invalid card");
}
}
After you read the implementation you spot a throw
for an invalid card. Is this enough?
Not really. What about getCreditCard
and getIsCardValid
?
function getCreditCard(email: string): Promise<Card> {
/// ...
}
function getIsCardValid(card: Card): boolean {
/// ...
}
function makePayment(params: { amount: number, email: string }): Promise<void> {
const card = getCreditCard(params.email);
const isCardValid = getIsCardValid(card);
if (isCardValid) {
return performPayment(card);
} else {
throw new Error("Invalid card");
}
}
Well, we don't know. The response type doesn't help.
We are required to read (and understand π€―) the internal implementation of both
This is a recursive problem: for every function call we must spot every throw
to handle all errors properly.
Solution: wrap everything in try/catch and call it a day ππΌββοΈ
function makePayment(params: { amount: number, email: string }): Promise<void> {
try {
const card = getCreditCard(params.email);
try {
const isCardValid = getIsCardValid(card);
if (isCardValid) {
try {
return performPayment(card);
} catch (errorPayment) {
/// Some error here π
}
} else {
/// Some error here π
throw new Error("Invalid card");
}
} catch (errorIsValid) {
/// Some error here π
}
} catch (errorGet) {
/// Some error here π
}
}
Or even better π
function makePayment(params: { amount: number, email: string }): Promise<void> {
try {
const card = getCreditCard(params.email);
const isCardValid = getIsCardValid(card);
if (isCardValid) {
return performPayment(card);
} else {
throw new Error("Invalid card");
}
} catch (error) {
/// Some error here π
}
}
We can call this global reasoning: you must read and understand all functions implementations to manage complexity
Local reasoning
Guess what? There is a solution for this:
function getCreditCard(email: string): Effect.Effect<Card, CheckingCardError> {
/// ...
}
function getIsCardValid(card: Card): Effect.Effect<true, InvalidCardError> {
/// ...
}
function makePayment(params: { amount: number, email: string }): Effect.Effect<..., InvalidCardError | CheckingCardError> {
return Effect.gen(function*(_) {
const card = yield* _(getCreditCard(params.email));
yield* _(getIsCardValid(card));
return yield* _(performPayment(card));
})
}
The errors from getIsCardValid
and getCreditCard
are propagated and collected inside makePayment
.
Even better: we do not need to know the actual implementation: the type signature is enough π‘
This scales complexity: we can focus on each implementation knowing that all errors and dependencies are collected and checked by the type system.
We call this local reasoning: you focus on the implementation of single function, and then compose them together.
No need to read any other function implementation, the type is enough
This is what Effect is all about.
Effect is the missing standard library for TypeScript: it provides a solid foundation of data structures, utilities, and abstractions to make building applications easier at scale
If you are interested to learn more, every week I publish a new open source project and share notes and lessons learned in my newsletter. You can subscribe here below π
Thanks for reading.