Toby Hobson

Why use Effect? 5 compelling reasons

banner.png
Why use Effect? 5 compelling reasons
Estimated reading time: 14 minutes
The Effect library for Typescript makes functional programming both practical and beneficial for real world projects. In this post I'll exlpain why you should try it.

The Effect documentation is pretty comprehensive, certainly by FP standards! However it largely focusses on how to use the library, not why you would want to do so. In this post I’ll try to explain why you might want to introduce Effect into your tech stack.

TL;DR The Effect library brings improved error handling, flexible dependency injection, request context propagation, retry logic, caching and much more! Better still, everything is strongly typed so we can leverage the Typescript compiler to the full.

What’s wrong with imperative code?

Nothing! The vast majority of Typescript / JS code is written in an imperative style. Many of the most popular frameworks and libraries adopt an imperative style.

However the same question could be asked of Typescript itself - lots of code is written in vanilla JavaScript, so why bother with Typescript? It’s not so much a question of “is imperative coding bad?” but rather “are there circumstances in which functional programming is better?”

I will outline some benefits to using Effect below, but first let me address a key criticism of functional programming - real world usage.

FP in the real world

FP encompasses concepts such as immutability, composition and pure functions (no side effects). However in the real world, we need side effects. Almost every application will involve some form of database access. Every application will need to throw and catch errors (which FP discourages).

We can seperate the data retrieval and persistence from the business logic, resulting in “pure” business logic functions. However this is often easier said than done - what if the business logic requires conditional data? We would need to fetch all the data the function may need - not very efficient.

That’s why most developers revert back to imperative coding when working on real world projects - applying FP feels like trying to make water run up hill.

As you might have guessed from the name, the Effect library is all about managing side effects, whilst offering the benefits of functional programming.

Benefits of Effect

Effect is a great fit for Typescript developers. It allows us to use the Typescript compiler to catch bugs in areas of our code that would typically get missed including:

  • Error handling
  • Dependency injection
  • Passing around request context

I’ll show you how Effect can improve type safety and give you a lot of other stuff for free.

This isn’t meant to be a tutorial, so please don’t try to copy and paste the code samples here. I’m trying to explain the why not the how. I will link the the relevant section of the Effect docs though, so you can get started if you choose.

Let’s look at the first area where Effect comes in handy. IMHO this is the biggest win you’ll get from introducing Effect into your stack, error handling…

Error handling

Take a look at this code:

const greet = (name: string): string => {
  if (name) return `Hello ${name}`
  throw new Error("name cannot be empty")
}

The function accepts a string argument and returns another string. That’s not actually true. Instead of returning a string, the function could throw an Error.

The Typescript compiler will check that callers supply a string and treat the return value as a string. However, it knows nothing about the potential error. Callers are free to ignore the potential error - never a good thing!

If callers do want to handle the error in a catch block, the compiler won’t help much either:

try {
  // tsc knows greeting is a string
  const message: string = greet("toby")
  ...
  return message
} catch (err) {
  // but it doesn't know anything about err
  if (err instanceof Error) {
    return err.message
  } else {
    // we should never end up here 🤯
  }
}

Functional error handling

Effect solves this problem by providing a Bifunctor implementation. What’s a Bifunctor? basically a wrapper that accounts for both the “happy” and failure scenarios:

// return type means this function could return a string or error
const greet = (name: string): Effect<string, Error> => {
  if (name) return Effect.succeed(`Hello ${name}`)
  return Effect.fail(new Error("name cannot be empty"))
}

const greetingOrErr = greet("toby")

// handle both scenarios by "matching" the string or error
const message = Effect.match(greetingOrErr, {
  // use the successful greeting as the message
  onSuccess: (greeting) => greeting,
  // use the error message as the message
  onFailure: (err) => err.message // tsc knows err is of type Error
})

Notice how we don’t need any type guards. The onFailure handler knows the error is of type Error, unlike a traditional catch block.

Two channels of execution

Having to handle potential errors after every function call is a pain. Fortunately effect lets us focus on the “happy” path, propagating errors up the stack:

const greetingOrErr = greet("toby")

// toUpperCase() will only be called if greet() was successful
const ucGreetingOrErr = Effect.map(greetingOrErr, (greeting) => greeting.toUpperCase())

We can also map the error channel

const greetingOrErr = greet("")

// transform an Error into a GreetingError
const greetingOrGreetingErr: Effect<string, GreetingError> = 
  Effect.mapError(greetingOrErr, (err) => new GreetingError(err.message))

We can operate on two channels of execution independently - the “happy” and “error” paths.

Tracking potential errors

Like try/catch blocks, Effect’s error handling is short-circuiting. If greet() returns an error, control flow will switch to the error channel unless we handle the error. Effect will track the potential errors that could occur as we call different functions:

type GreetingError = { _tag: "GreetingError" }
type LoggingError = { _tag: "LoggingError" }

const greet = (name: string): Effect<string, GreetingError> => ...
const log = (message: string): Effect<void, LoggingError> => ...

// call greet then log the greeting (how we do this isn't relevant here)
// resulting error could be a GreetingError or LoggingError
// effect tracks these potential errors for us using a union
const loggedGreeting: Effect<string, GreetingError | LoggingError> = ...

// we now need to handle 3 scenarios:
// string (success)
// GreetingError or
// LoggingError
const message = Effect.match(loggedGreeting, {
  onSuccess: (greeting) => greeting,
  // use the _tag as a descriminator
  onFailure: (err) => {
    switch(err._tag) {
      case "GreetingError": return "Sorry i can't greet you"
      case "LoggingError": return "Sorry i can't log that"
    }
  }
})

Summary

Effect allows us to operate on two channels independently, with full typechecking of both channels. This is really powerful because it allows us to treat errors with as much care and attention as our happy path of execution.

Read more about Effect’s error handling in the docs.

Lazy evaluation

Assume we want to fetch an order from a database:

const getOrder = (id: string): Promise<Order> => Promise.resolve({})

How would we handle a potenial network error during the order retrieval? We’d probably wrap the call with some retry logic. Following the DRY principal, lets create a higher order function that wraps another function with retry logic:

const withRetry = <A>(fn: () => Promise<A>): Promise<A> => {
  return fn().catch((err) => {
    if (err instanceof NetworkError) {
      return fn() // try again
    } else throw err
  })
}

const order = await withRetry(() => getOrder("123"))

Note how withRetry accepts a function that returns a Promise, not a Promise itself. Callers aren’t passing a result into the function, they’re telling it how to create the result.

AOP with Effect

By suspending the evaluation we can add all sorts of behaviour, sometimes referred to as Aspect Oriented Programming including:

  • Caching
  • Batching
  • Interruption / cancellation
  • Resource management
  • Tracing / telemetry

But coding all this would be a LOT of work. Fortunately we get it for free when using Effect. Let’s rewrite the retry logic using constructs from the Effect library.

// wrap the promise creation in an Effect using the tryPromise helper
const getOrderEffect = Effect.tryPromise(() => getOrder())

// now we can add retry logic
const order = Effect.retry(getOrderEffect, { times: 2 })

Read more about Effect’s features in the docs

Dependency injection

Most projects require some form of dependency injection. Many Typescript developers choose to wire up dependencies themselves. The pattern is basically to pass a dependency into a class constructor or factory function:

class OrderRepositoryImpl implements OrderRepository {
  getOrder(id: string) { ... }
  putOrder(order: Order) { ... }
}

class OrderServiceImpl implements OrderService {
  constructor(private readonly repository: OrderRepository) { }
  retrieveOrder(id: string) { ... }
  createOrder(order: Order) { ... }
}

Wiring up the dependencies can quickly become tedious, so frameworks can be employed to do some or all of the wiring. The problem is that most frameworks wire the dependencies up at runtime meaning:

  1. There’s a runtime overhead
  2. We need to run the app to verify it’s working correctly

Regarding the second point, you might be tempted to say “that’s why I write tests”. However the main reason for using dependency injection is to allow us to use different wirings for test vs production. You could well find your code compiles and all tests pass, yet the code blows up in production 😱

Functional dependency injection

Effect takes a completely different approach. Firstly we don’t need to use classes (although we can) - we can inject dependencies into individual functions. Let’s look at a function signature that uses Effect for functional dependency injection:

const retrieveOrder = (id: string): Effect<Order, NotFoundError, OrderRepository> => ...

The return type tells us:

  1. The function returns an Order
  2. It could also return a NotFoundError
  3. It requires an OrderRepository

Remember Effect is lazy, calling retrieveOrder won’t actually do anything - it just returns a description of the work that needs to be done. When we try to actually run the effect the compiler will complain:

const orderEffect = retrieveOrder("123")
// this won't compile
const orderPromise = Effect.runPromise(orderEffect)

The Effect.runPromise call will result in a compiler error, complaining that we need to provide an implementation for OrderRepository

Removing leaky abstractions

Like errors, dependencies also propagate up the call stack. If displayOrder calls retrieveOrder, it’s dependencies would need to include the OrderRepository (required by displayOrder):

const displayOrder = (id: string): Effect<..., OrderRepository> => ...

This can result in a leaky abstraction - callers of retrieveOrder shouldn’t need to know about it’s dependencies. Fortunately Effect introduces a concept known as Layers which allow for something closer to the traditional class based dependency injection:

type OrderService = {
  // retrieveOrder no longer depends on the OrderRepository 
  retrieveOrder: (id: string) => Effect<Order, NotFoundError>
}

// similar to a class in traditional dependency injection
const OrderServiceImpl = Layer.effect(
  OrderService,
  Effect.gen(function* (_) {
    // inject an OrderRepository
    const repository = yield* _(OrderRepository)
    return {
      retrieveOrder: (id) => { 
        // use the repository
        const order = repository.getOrder(id)
        ...
      }
    }
  })
)

Instead of functions depending on services, we’re now back to services depending on each other. However Unlike TSyringe, NestJS etc. the layer based dependency injection also happens at compile time. We don’t need to use annotations, reflection or any other tricks. The compiler will catch any wiring errors for us.

Mixing and matching styles

We can also mix and match Layer (service based) dependency injection with functional dependency injection. We wire up the depedencies whilst creating the services, but leave some dependencies to be provided when specific functions are invoked. This makes for a really nice pattern…

Passing context around

Imagine we want to implement a primative form of tracing across our Express app. We’ll generate a unique id for each request and reference it in all downstream log statements:

console.log('%s: creating order', requestId)
...
console.log('%s: listing orders', requestId)

How do we pass the requestId around? We could pass it as an argument but that quickly becomes tiresome. We’d most likely resort to something like Async Local Storage, at least on Node. There are three problems with Async Local Storage:

  1. It’s untyped - We need to wrap the underlying api or employ something like NestJS CLS
  2. We need to handle nulls - The compiler won’t ensure we called run or enterWith
  3. Unit testing is hard - Our unit tests require node specific code

Passing context with Effect

I mentioned that we can choose to use functional dependency injection alongside the more traditional class/service based injection (known as Layers in Effect). Take a look at this function signature:

type RequestId = { requestId: string }

const createOrder = (order: Order): Effect<Order, never, RequestId> => ...

const listOrders = (userId: string): Effect<Order[], never, RequestId> => ...

You can ignore the never - it just means the function can never generate an error (unlikely in the real world!). Notice how the functions require a RequestId to be provided when running the effect i.e. kicking off the underlying Promise.

We can use Effect’s functional dependency injection not just for traditional dependency injection but also as a means of passing runtime context around. Unlike Async Local Storage, it’s built into the framework so it works out of the box on Node, browsers and all test frameworks.

Here’s how we might use this concept in an Express app

app.post('/order', (req, res) => {
  const requestId = "request1"
  
  // call createOrder followed by listOrders
  // notice how the third type argument is RequestId
  // as both createOrder and listOrders require it
  const effect: Effect<Order[], never, RequestId> = ...
  
  // provide the requestId 
  // note how the third type arg changes from RequestId to never
  const withId: Effect<Order[], never, never> = 
    Effect.provideService(effect, RequestId, { requestId })
  
  const response = Effect.runPromise(withId)
})

We’re not restricted to passing values via dependency injection, we can pass anything:

type Logger = { 
  log: (message: string) => void 
}

// both functions require a Logger
const createOrder = (order: Order): Effect<Order, never, Logger> => ...
const listOrders = (userId: string): Effect<Order[], never, Logger> => ...

...

const requestId = "request1"

// create a Logger implementation that includes a request id
const logger = {
  log: (message: string): void => console.log(`${requestId}: ${message}`)
}

const withLogger: Effect<Order[], never, never> = 
  Effect.provideService(effects, Logger, logger)

Here we’re pimping the logger for each request, adding a request specific field to the message. We can use this concept to implement many cross-cutting concerns including:

  • Logging
  • Tracing
  • Access control
  • Request level caching

We’re basically inverting the context. Instead of pushing it down the stack, the downstream functions are pulling some behaviour in at runtime. Because Effect is lazy, we can provide this behaviour at a time of our choosing.

Testing

I’ve found that being able to inject dependencies into specific functions makes testing much easier. Let me show you an example:

// inject a function instead of a service
type GetOrderFn = (id: String) => Effect<Order, NotFoundError>
...
const retrieveOrder = (id: string): Effect<Order, NotFoundError, GetOrderFn> => ...

/* tests */

const requiresFn = retrieveOrder("123")

// stub the GetOrderFn
const stubbedFn = () => Effect.succeed(Fixture.order)

// provide it to the Effect
const withStubbedFn = Effect.provideService(requiresFn, GetOrderFn, stubbedFn)

This is much easier than mocking/stubbing a complete class or interface. You can still use Layers to avoid leaky abstractions:

type GetOrderFn = (id: String) => Effect<Order, NotFoundError>

const retrieveOrder = (id: string): Effect<Order, NotFoundError, GetOrderFn> => ...

type OrderService = {
  // doesn't depend on GetOrderFn
  retrieveOrder: (id: string) => Effect<Order, NotFoundError>
}

const OrderServiceImpl = Layer.effect(
  OrderService,
  Effect.gen(function* (_) {
    // inject an OrderRepository
    const repository = yield* _(OrderRepository)
    return {
      retrieveOrder: (id) => { 
        // call the retrieveOrder function (which requires a GetOrderFn)
        const requiresFn = retrieveOrder(id)
        // provide OrderRepository's getOrder function
        const withFn = Effect.provideService(requiresFn, GetOrderFn, repository.getOrder)
      }
    }
  })
)

The work is performed in the outer retrieveOrder function, which depends on the GetOrderFn. We’re just wrapping the function in a layer and providing the dependency during the Layer/Service creation.

Summary

One of the biggest benefits of using Effect is error handling. We can apply the same type safety to errors as we do for our primary (“happy”) path. TSC will force us to handle potential errors, and all errors will be typed - no more unknowns in catch blocks 👏.

As Effect suspends execution it allows us to do clever things including: generic retry logic, batching, caching, access control etc. These cross cutting concerns typically require some form of runtime engine e.g. NestJS. With Effect we don’t need any such engine. We can use TSC to check for mistakes at compile time.

Dependency injection is essential for all but the smallest code bases. Effect’s functional and layer based dependency injection allows us to wire up services, whilst also providing dependencies during specific function calls. This not only simplifies testing, it also allows us to pass request context around, without resorting Async Local Storage.

I hope you found this post useful. I’ll follow up with some more posts explaining how to do the things I’ve covered here. The Effect documentation is also very good, and I urge you to check it out.

comments powered by Disqus

Need help with your project?

Do you need some help or guidance with your project? Reach out to me (email is best)