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.
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:
- There’s a runtime overhead
- 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:
- The function returns an
Order
- It could also return a
NotFoundError
- 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:
- It’s untyped - We need to wrap the underlying api or employ something like NestJS CLS
- We need to handle nulls - The compiler won’t ensure we called
run
orenterWith
- 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.