Back to Journal

Technical May 16, 2026 7 min read

Your TypeScript Backend Is Slop Without Effect

Plain TypeScript hides its failure modes. The honest case for making Effect the default on any backend that actually matters.

Isometric illustration of three stacked translucent layers in green, lavender and gold, representing Effect's three-channel type Effect<A, E, R>: the statically typed success value, the exhaustive error channel, and the requirements provided by Layer

Most TypeScript backends work fine. Right up until they don't. The slop isn't in your syntax or your formatting, your linter already handles that. It's in the gap between what your function signatures promise and what your code actually does at 2am when a payment provider starts timing out.

QUICK ANSWER

A plain TypeScript backend hides its real failure modes. Thrown errors never appear in a type signature, async code leaks resources when work gets cancelled, and dependencies are wired together by hope. Effect moves errors, resource cleanup, and dependency injection into the type system, where the compiler checks them. In 2026, with the library stable and the docs finally good, running a serious backend without it means hand-rolling all of that yourself.

Backend architecture is mostly about failure. Anyone can write the happy path. The job is deciding what happens when the happy path doesn't.

What I Mean By Slop

Let's be precise, because "slop" sounds like an insult and it isn't one. Your code is probably clean. Sensible names, tidy formatting, tests that pass.

The slop is structural. A function typed Promise<User> is making a promise it cannot keep. It can throw. It can reject with literally any value. It can hang forever. The signature says one thing and the runtime does another, and TypeScript will not say a word about the difference.

Slop is that distance between the signature and the behaviour. In a small service you can hold it in your head. In a real codebase it compounds, one unchecked assumption per function, until nobody on the team can say with confidence what happens when a given call fails.

Your Type System Is Lying To You

TypeScript tracks what a function returns. It does not track what a function throws. There is no throws clause, no checked exceptions, nothing. When you catch an error, you get unknown.

So every error boundary in your application is an any wearing a disguise.

async function getUser(id: string): Promise<User> {
  const row = await db.query("select * from users where id = $1", [id]);
  return parseUser(row);
}

// the caller has no idea what just failed:
try {
  const user = await getUser(id);
} catch (e) {
  // e is `unknown`. connection error? bad row? who knows.
}

Three separate things can fail in that function and the signature mentions none of them. The query can throw a connection error. parseUser can throw on a row that doesn't match the shape. The whole call can hang if the connection pool is drained. The caller catches e, gets unknown, and now has two options: cast it to something specific (a lie the compiler will happily believe) or log it and move on (slop, shipped to production).

You didn't choose this. The language just doesn't give you a way to write the failure down.

async/await Falls Apart Under Pressure

async/await is great for a happy path and has almost nothing to say about the hard parts: cancellation, partial failure, and cleanup.

Promise.all rejects the moment one promise fails, which sounds correct until you notice the other promises are still running. Still holding connections. Still burning a rate-limited API quota. Nobody is listening to them and nobody told them to stop.

Want to cancel an operation if it takes longer than two seconds? You race it against a timeout, and the slow operation keeps going in the background after the timeout wins. You leaked it. Want to guarantee a connection returns to the pool when a function three frames up the stack throws? You reach for finally blocks and hope you covered every path. You did not cover every path.

None of this is exotic. It is Tuesday on any backend that talks to more than one thing. We've written before about how the boring middle layer is where production systems actually break.

What Effect Actually Changes

Effect's core type is Effect<A, E, R>. Three slots, all checked by the compiler: the value it produces (A), the errors it can fail with (E), and the dependencies it needs to run (R).

Here is that same function, rewritten:

const getUser = (id: string): Effect.Effect<User, DbError | ParseError, Database> =>
  Effect.gen(function* () {
    const db = yield* Database;
    const row = yield* db.query(id);
    return yield* parseUser(row);
  });

The signature finally tells the truth. It returns a User. It can fail with a DbError or a ParseError and nothing else. It needs a Database to run. Forget to handle ParseError somewhere upstream and the build fails. Not a lint warning you can scroll past. The build.

That typed-error story is the headline. The rest of what you get matters just as much.

Resource safety that actually holds. Effect.acquireRelease pairs an acquire step with a release step that runs no matter how the program exits: success, failure, or interruption. Connections go back to the pool. Files close. You stop writing finally blocks and hoping.

// release runs on success, failure, AND interruption
const conn = Effect.acquireRelease(
  openConnection,
  (c) => c.close()
);

Structured concurrency. Run work in parallel and a failure cancels its siblings instead of orphaning them. Interruption is part of the model, not something you simulate with boolean flags and prayer.

Dependency injection the compiler checks. Layer wires your services together, and the R slot refuses to let a program run until every dependency it asked for has been provided. No DI container, no runtime "could not resolve" surprise in staging.

Retries, timeouts, and schedules are composable values too, with real backoff policies, instead of a hand-rolled loop copy-pasted between three services. And Schema parses untrusted input into typed values, with the parse failures landing in the E slot like every other error.

"Make illegal states unrepresentable."

— Yaron Minsky, Jane Street

That principle is more than a decade old and still mostly aspirational in TypeScript backends. We got reasonably good at applying it to data with discriminated unions. Effect is the first thing that makes it practical for errors and effects, the parts of a program that were always quietly allowed to do whatever they wanted.

But neverthrow Already Gives Me Typed Errors

Fair objection. Libraries like neverthrow give you a Result type, so failures become values instead of exceptions. If typed errors were the whole story, that would be enough.

They are not the whole story. A Result tells you a function can fail. It says nothing about whether that function leaked a connection on the way out, whether its dependencies were satisfied, whether it can be cancelled, or how it retries. You still hand-roll all of that, except now you are hand-rolling it around a Result type.

Effect makes a bigger claim than "typed errors are good." Everyone already agrees on that. The claim is that errors, resources, dependencies, concurrency, and scheduling are really one problem, and that solving them in a single consistent model beats bolting together five libraries that each handle a slice and don't compose with each other.

The Honest Tradeoffs

Effect is not free, and anyone who tells you otherwise is selling something.

The learning curve is real. Effect.gen is built on generators, and yield* everywhere looks alien for the first week. The three type parameters take a project or two before you stop fighting them. Budget for the ramp instead of pretending it isn't there.

It wants your whole call graph. Effect is happiest when it owns the program end to end. Adopt it halfway, an Effect core with promise-based edges, and you live on an awkward boundary where you are constantly converting between two worlds. It works. It is just the least pleasant way to use the thing.

It needs team buy-in. One engineer writing Effect in a codebase nobody else reads is a bus factor, not a win. This is a team decision, or it shouldn't be a decision at all.

And the concession that keeps this honest: a 300-line service with two endpoints and one table does not need any of this. If your backend genuinely has no interesting failure modes, plain TypeScript isn't slop. It is just the right amount of tool for the job.

So When Is It Actually Slop?

Here is the line. Your backend is slop, in 2026 specifically, when it has real failure modes and you are still managing them by hand.

Payments. Orchestration across three or four services. Background jobs and queues. Anything where a swallowed error quietly costs money, or a leaked connection takes down a pod at peak traffic. For that class of system, plain TypeScript means hand-rolling, usually badly and inconsistently, the exact things Effect hands you as checked, composable defaults. A production-ready system is mostly a pile of well-handled failure modes, and Effect is the cheapest way to keep that pile honest.

I say "in 2026 specifically" on purpose. A couple of years ago you could fairly argue the API was still moving and the docs were rough. That argument has expired. The library is stable, the documentation is genuinely good now, and the surrounding ecosystem is real. The reasons to wait mostly aren't reasons anymore.

Bottom Line

Effect will not make your code correct. You can write bad logic in any paradigm, and you will. What it does is move an entire category of bugs, unhandled errors, leaked resources, missing dependencies, from "discovered in production" to "rejected at compile time."

On a backend that matters, that trade is worth the learning curve. On a backend that doesn't, it isn't, and you should say so out loud. The actual slop is shipping a system with serious failure modes while pretending you don't have the tools to handle them. In 2026, you do.

Weighing up Effect for a backend that actually matters? We've run the migration on production systems. We review architectures.

Have a question?

We're always happy to chat about backend architecture and Effect adoption. No sales pitch required.

Get in Touch