We love Effect so much we shipped 100 lines of it
Why Urban distills Effect instead of depending on it — and where Nano picks up where Effect leaves off
Effect is having a moment, and deservedly so. v4 lands the clearest version yet of an idea TypeScript has needed for years: make effects — errors, async, resources, concurrency — first-class values the compiler can reason about, instead of exceptions you hope someone remembers to catch.
We build Nano and its application framework, Urban, in TypeScript. So people ask the obvious question: are you using Effect?
The honest answer is more interesting than yes or no. We looked hard at Effect, loved the model, and shipped ~100 lines of it into Urban instead of taking the dependency. Here’s the reasoning — and why, for our layer of the stack, that was the right call rather than a compromise.
What we actually reach for
Strip Effect down to what a framework’s glue code — workers, provisioning, resource lifecycles — reaches for every day, and it’s a short list:
- A typed error channel. A function that can fail should say so in its
type, and the failure should compose automatically through a sequence of steps,
short-circuiting on the first error.
Effect.gen+yield*is the canonical ergonomic here. - Tagged errors with exhaustive handling. Model each failure mode as a
discriminated variant, then let the compiler force you to handle every one — no
silently dropped case. That’s
Data.TaggedError+catchTags. - Scoped resources. Acquire something, guarantee its release on
every exit path — success, failure, or a thrown exception. That’s
Effect.scoped+acquireRelease.
Those three carry the vast majority of the day-to-day value. So we built exactly those
three, and nothing else, as effectlite: a zero-dependency, Effect-like
core that lives in @nanobpm/urban’s src/effect.
Result<A, E> with generator do-notation whose E composes through
yield* just like the real thing; tag() + exhaustive
matchTags; and scoped + acquireRelease. About a hundred
lines of implementation, no runtime deps.
yield* — and an exhaustive match the compiler enforces.import { gen, ok, fail, tag, matchTags } from "@nanobpm/urban/effect";
const result = gen(function* () {
const repo = yield* cloneRepo(url); // yields Fail<CloneError> on failure
const built = yield* build(repo); // yields Fail<BuildError> on failure
return built; // Result<Artifact, CloneError | BuildError>
});
// The compiler forces a handler for EVERY failure mode — omit one and it won't compile.
matchTags(result.error, {
CloneError: (e) => retryLater(e),
BuildError: (e) => report(e),
});
If you know Effect, that reads like home. That’s the point.
Why not just depend on effect?
Two reasons, and neither is a knock on Effect — they’re about our layer.
1. Bundle weight against a polyglot, embed-everywhere surface. Urban’s published value isn’t a TS runtime — it’s a portable app model plus a client renderer that ships to many runtimes: Node, and embedded hosts on the JVM, GraalVM, and Deno, feeding language kits for Java, Rust, Python, C# and more. The imperative glue that benefits from Effect’s ergonomics is a thin seam around a declarative core. Pulling a full effect runtime into that seam is weight in exactly the place we work hardest to keep light.
2. The viral paradigm. Effect’s greatest strength —
Effect<A, E, R> colouring every signature so the compiler tracks errors and
requirements end-to-end — is also a whole-codebase commitment. It pays off spectacularly when
your entire application is written in it. It pays off far less when you want three
ergonomics in the 5% of your surface that’s imperative, while keeping the other 95% plain,
portable, and dependency-free. Adopting the paradigm halfway is the worst of both worlds;
distilling the three pieces we use is the best of both.
This isn’t “Effect is too heavy.” It’s “Effect is a runtime for making an entire application robust, and our application’s robustness lives one layer down — in the engine, not the language.”
The real story: durable > in-process
Here’s the part worth internalizing, because it’s where Nano and Effect genuinely complement each other rather than compete.
Effect makes a process robust. Fibers, typed errors, resource safety — all of it operates inside a running program. It is the best-in-class answer to “how do I make this program correct and resilient while it runs.”
Nano makes the workflow robust. Nano Workforce orchestrates the whole software lifecycle — plan, implement, review, test, merge — as a durable graph on a Rust engine that’s Camunda 8 API compatible. When the process crashes, the machine reboots, or you redeploy mid-run, the graph resumes at the exact step it left off; journal-committed steps are never replayed.
That’s a different axis. In-memory structured concurrency — however elegant — ends when the
memory does. A fiber does not survive kill -9. A durable graph does. For work that
spans minutes to weeks, waits on humans, and must outlive every process that touches it,
durability isn’t a nicer error channel — it’s the whole game.
So the two compose cleanly:
- Effect, inside your workers — make each activity correct and resilient while it runs.
- Nano, around the workers — carry the run across everything that outlives the process.
You lose nothing by loving both. If you want the Effect model in the code Nano drives, use it — and let Nano own the part Effect was never trying to own.
The takeaway
We’re not on the Effect bandwagon, and we’re not going to pretend to be — the community would
spot a missing import { Effect } in about four seconds. What we are is a
team that admired the model enough to distill its best ideas into a hundred honest lines, and a
stack that picks up exactly where an in-process effect system has to stop: at the boundary of the
process itself.
Effect for the process. Nano for the workflow.
See where this fits in the stack on the Nano architecture page, or try the durable engine in your browser at nanobpm.io/demo.