Cooper Maruyama
丸山

half-stack developer

Understanding Why You'd Use Effect TS

This is for people who have heard about Effect recently and either can't quite wrap their head around it or, more usefully, just want to know whether they should use it.

If you're newer to TypeScript and only want a recommendation: yes, use it. You have fewer habits to unlearn than the rest of us, and the things Effect makes you think about are things you'd have to learn eventually anyway. If you want to understand why people like it, read on.

The confusing thing about Effect is the sheer size of it. It exports a lot, and for a long time (the v3 docs especially) it was hard to see the benefits through the surface area. So instead of a tour of the API, this is a list of the benefits as I see them, roughly in order of how defensible they are. The first one is objective. Everything after it is about developer experience, and you should read it as one person's opinion rather than a universal truth.

1. Promises don't tell you how they fail

Suppose you've just written this:

export async function checkout(cart: Item[], customer: Customer) {
  const order = await createOrder(cart)
  const payment = await charge(customer)
  const shipment = await ship(order, payment)
  return await createReceipt(order, shipment)
}

Now answer a question: what can go wrong here?

You can't tell from the code, and neither can the compiler. If tomorrow we stop shipping to a certain country and ship starts throwing a new error, does any caller of checkout find out? No. Even if you wrap the whole thing in a try/catch, you don't know which of the four steps failed without picking apart the error object at runtime. The type of this function is Promise<Receipt>. The word "failure" appears nowhere in it.

Here is the same function in Effect:

const checkout = (cart: Item[], customer: Customer) =>
  Effect.gen(function* () {
    const order = yield* createOrder(cart)
    const payment = yield* charge(customer)
    const shipment = yield* ship(order, payment)
    return yield* createReceipt(order, shipment)
  })
// Effect<Receipt, EmptyCart | CardDeclined | CountryNotServiced>

Nobody wrote that error union. It was inferred from the four functions being called, whose signatures look like this:

createOrder:   (cart: Item[])                     => Effect<Order,    EmptyCart>
charge:        (customer: Customer)               => Effect<Payment,  CardDeclined>
ship:          (order: Order, payment: Payment)   => Effect<Shipment, CountryNotServiced>  // ← the new one
createReceipt: (order: Order, shipment: Shipment) => Effect<Receipt>

So what happens when someone adds CountryNotServiced to ship? checkout itself keeps compiling. Its error type just gets wider, and so does the type of everything that calls it, all the way up the stack. The compiler complains at the first place that assumed the old, closed set of errors. Usually that's the edge of your program, where errors get turned into responses:

const handler = checkout(cart, customer).pipe(
  Effect.map((receipt) => reply(200, receipt.id)),
  Effect.catchTags({
    EmptyCart:    ()  => Effect.succeed(reply(400, "cart is empty")),
    CardDeclined: (e) => Effect.succeed(reply(402, e.reason)),
  }),
)
// Effect<Reply, CountryNotServiced>
//               ^ still here. Anything that requires a fully handled effect refuses it:
//   Type 'CountryNotServiced' is not assignable to type 'never'.

That's the whole trick. Errors are part of the return type, so a new failure mode three functions deep becomes a compile error at the place that has to deal with it, not a surprise in production.

This isn't a subjective improvement. It's not "it looks nicer." A class of bugs that used to be discovered by users is now discovered by the compiler, and that will improve the reliability of your programs in a very concrete way.

"But neverthrow does this too"

It does. So what's special about Effect?

Ask ten people and you'll get ten answers, but I think they'd share this: Effect is the first TypeScript library that lets us do real functional programming in a way we're actually satisfied with.

The previous attempts (fp-ts most notably, whose author later joined the Effect team) were correct, and I never liked how they made my code look. I knew the benefits of functional programming. I just couldn't picture onboarding someone onto pipe(chain(map(...))) and having it feel worth it.

So Effect didn't make anything possible that was impossible before. It made functional programming something you don't have to apologize for in code review, and in a lot of places it improved the developer experience outright.

This is where the objective part of the post ends. Everything below is about how it feels to work in an Effect codebase.

2. Service dependencies the way we always wanted them

Look at the core Effect type. Unlike neverthrow, and unlike every Option/Result type you've seen, which have a success type and an error type, there's a third parameter:

       ┌─── Success type
         ┌─── Error type
           ┌─── Requirements 🧐
           
Effect<A, E, R>

I'd bet money that for most of us who were initially skeptical, understanding that third type was the moment we went from "so this is another neverthrow" to "okay, I'm going to use this."

Here's the idea that makes it possible. An Effect<A, E, R> is a description of a computation, not a running one. Because nothing has executed yet, the type can carry "this needs a Database and a Clock," and something at the edge of the program can supply them later. Similar to how React got everyone doing functional programming without telling them, Effect smuggles in two ideas here:

  1. Effects as values.
  2. Dependency injection.
import { Context, Effect, Layer } from "effect"

class Db extends Context.Service<Db, {
  readonly find: (id: string) => Effect.Effect<User>
}>()("@app/Db") {}

class Log extends Context.Service<Log, {
  readonly info: (msg: string) => Effect.Effect<void>
}>()("@app/Log") {}

// Nobody declares dependencies. They're inferred from use and bubble up.
const getUser = (id: string) =>
  Effect.gen(function* () {
    const db = yield* Db
    return yield* db.find(id)
  })
// Effect<User, never, Db>

const greet = (id: string) =>
  Effect.gen(function* () {
    const user = yield* getUser(id)
    const log = yield* Log
    yield* log.info(`hi ${user.name}`)
    return user
  })
// Effect<User, never, Db | Log>
// Db came along from getUser. Log was added here. greet never mentioned Db.

// Wire once, at the edge.
const DbLive  = Layer.succeed(Db,  { find: (id) => Effect.succeed({ id, name: "Cooper" }) })
const LogLive = Layer.succeed(Log, { info: (m) => Effect.sync(() => console.log(m)) })

Effect.runPromise(greet("1").pipe(Effect.provide(Layer.merge(DbLive, LogLive))))

// Test: swap one layer. Zero application code touched.
const DbTest = Layer.succeed(Db, { find: () => Effect.succeed({ id: "x", name: "fixture" }) })
Effect.runPromise(greet("1").pipe(Effect.provide(Layer.merge(DbTest, LogLive))))

// Forgot Log? runPromise requires R = never. Compile error, not a 3am null pointer.
Effect.runPromise(greet("1").pipe(Effect.provide(DbLive)))
//   Argument of type 'Effect<User, never, Log>' is not assignable to
//   parameter of type 'Effect<User, never, never>'.

To me, this is the main reason people say they "love" Effect, which is not a normal thing to feel about a JavaScript library.

None of us are ever fully satisfied with the state of our code, and I think a lot of us, especially anyone who spent time in Rails or Django or the Elm architecture, have always felt that TypeScript never got its convention-heavy framework. We like to make fun of WordPress, but look at how its plugin system works: there are entire businesses whose only product is a WordPress plugin, because the architecture makes its extension points explicit. The Requirements channel is the primitive you would build that kind of thing on. A piece of code says exactly what it needs, and the framework supplies it.

3. The mental model: outputs and inputs

When I think back to when I started programming, I can see how I'd look at the code above and not see the value in it. Once you've worked for a while you look at it and immediately recognize how much easier it makes testing, or running the same code against multiple runtimes. This section is for the reader who isn't there yet.

Errors are return values. They flow out of a function, the same way the success value does. Requirements flow in. For a pure function those are the only two directions anything can travel, so once the signature covers both, A and E going out and R coming in, there is nothing left to discover about how the function relates to the world. The signature is the context.

That changes what debugging feels like. Most of the time you get your answer from the one line where the function is defined. What you stop doing is the thing we've all done a thousand times: read a line, put a mental bookmark on it, scroll up to find where the thing it references was imported, bookmark that, jump into the other file, bookmark that, come back down, and hope the stack of bookmarks in your head hasn't fallen over.

What "pure" buys you

In a pure functional language the rule is that a function is not allowed to touch anything that wasn't passed to it as an argument.

function foo(a, b) {
  return a + b
}

Everything foo can possibly do is determined by a and b. In JavaScript we violate this constantly. The moment you console.log inside a function, you've broken the rule. If you tried to rewrite your code to obey it you'd find it puts a lot of constraints on you, and at first that's annoying. But where some programmers see annoyance, a lot of us see the removal of an entire class of mistakes.

The root cause of most bugs is complexity, and clear contracts are how you fight complexity. In a pure function, the arguments are the whole search space when something goes wrong. In an impure one, the search space is the body plus every global and every module it reaches for. It's the difference between reading one line and scanning the entire body, which is many times more complex.

Hidden dependencies

Let's go back to the checkout example and look at charge:

import { stripe } from "./stripe"   // hidden dependency
import { db } from "./db"           // hidden dependency

const charge = async (customer: Customer) =>
  stripe.charges.create({ customer: customer.id })

You've seen a lot of code like this. You've written a lot of code like this, and it's genuinely hard to see what's wrong with it. Even if you dropped the async and made it return an Effect, those two imports would still be sitting at the top of the file.

This isn't about forgetting an import; TypeScript catches that. It's about where the dependency lives. Back when Webpack was bundling all of our apps, a module was literally a function wrapped around your file, and those imports were its free variables. That's still the right way to think about them: charge reaches outside of itself for stripe, and nothing in its signature says so.

Debugging this is the bookmark dance from earlier. You read a line inside charge, think about it, scroll to the top to see which module stripe came from, scroll back down, and repeat.

Here's the same thing as a service:

class Payments extends Context.Service<Payments>()("@app/Payments", {
  make: Effect.gen(function* () {
    const stripe = yield* Stripe   // Payments' own dependencies live here, not in charge
    return {
      charge: (customer: Customer) => stripe.charge(customer.id),
    }
  }),
}) {
  static readonly layer = Layer.effect(this, this.make)
}

const charge = (customer: Customer) =>
  Effect.gen(function* () {
    const payments = yield* Payments
    return yield* payments.charge(customer)
  })
// Effect<Charge, CardDeclined, Payments>

It looks almost the same. The body barely changed. But by using Payments inside the body, we changed the type of charge. It now says Payments in its signature, and that one word does a lot of work:

  • Anyone reading the signature knows charge talks to the payment system, without opening the body.
  • Anyone calling charge inherits that requirement. It bubbles up until something provides it, and if nothing does, the program doesn't compile.
  • Testing charge means providing a different Payments layer, not vi.mock("./stripe") and hoping the module graph cooperates.
  • The scroll-up-and-down dance is gone. The imports at the top of the file are types and tags, not live objects with behavior. What the function can do is fully determined by what's in its signature.

The mental cost of understanding a function went from growing with the size of the file to being constant.

4. Guardrails you can't walk past

All things being equal, if I had to choose between code that's easy to understand and code that's slightly faster but harder to understand, I'd take easy to understand every day of the week. Simple, maintainable code is what leads to software people like to use. When a codebase is complex, everything costs more, so things get neglected. Look at most production TypeScript and you can see the result.

As you get better at programming you start to want stricter conventions, and I don't think it's because you doubt your own discipline. It's because you can't control who comes after you. Of every framework I've worked in, Rails did this best. It decided how models are defined, what middleware looks like, and how data gets to the frontend, and as a result most Rails apps look the same. You can move from one to another and it doesn't feel like a different codebase. TypeScript has never had that, and that's what I think experienced developers recognize when they start using Effect: the shape it imposes gives you some of that back.

Every codebase has areas where you feel a little alarm as you approach them. It's like walking on wet marble. You know someone is going to slip here. The conventional fix is a yellow sign: a lint rule, a big comment block, a warning in the README. Programmers hate that fix. If someone doesn't look at the sign they still fall, and there is always someone who doesn't look at the sign. What we actually want is a floor you can't slip on.

That's what Effect feels like. The hidden-dependency charge above is exactly the kind of place I'd have put up a sign, and with Effect there's nothing to warn about, because the mistake doesn't type-check. Finally a real solution instead of a suggestion.

This matters more now than it did two years ago, because agents write a lot of our code, and agents are at least as bad as people at reading signs. A type error is a sign an agent can't walk past.

So, should you use it?

  • If your code has meaningful I/O, meaning external APIs, databases, queues, config, retries, and more than one of those at once, yes. That is where typed errors and the Requirements channel pay for themselves.
  • If you're writing a small script or pure data transformations, plain TypeScript is fine. Effect is not a replacement for simple code, and wrapping simple code in it just adds ceremony.
  • If you're on a team, budget the time to sell it, not just install it.

The objective argument is section 1: the compiler now tells you about failure modes you used to find in production. Everything after that is about what it's like to work in a codebase where the signature tells you the truth. For me that feeling is the reason I keep reaching for it.