β€’

tech

Scale complexity in software applications

A new paradigm to scale complexity in software applications: use types to define errors and dependencies, and use the compiler to guide the implementation and unlock local reasoning.


Sandro Maglione

Sandro Maglione

Software development

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 payment
  • ConnectionError | PaymentError | WrongParametersError: If the function fails, we know exactly why and where, so we can respond accordingly
  • PaymentService | 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.

πŸ‘‹γƒ»Interested in learning more, every week?

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.