Effect-TS pulls Scala’s ZIO into TypeScript: typed effects, typed errors, dependency injection, fibers. It’s powerful, opinionated, and has a real learning curve. This post is the honest take on when it pays off.
The Effect type
import { Effect } from "effect";
const program: Effect.Effect<number, never, never> = Effect.succeed(42);
// ^success ^error ^requirements
Effect<A, E, R>:
A: success value type.E: error type (typed!).R: required services (DI).
Compare to a Promise: Promise<A> — errors are untyped (any), no DI primitive.
Typed errors
class NotFound { readonly _tag = "NotFound"; constructor(readonly id: string) {} }
class DbError { readonly _tag = "DbError"; constructor(readonly cause: unknown) {} }
const findUser = (id: string): Effect.Effect<User, NotFound | DbError, Database> =>
Effect.gen(function* () {
const db = yield* Database;
const row = yield* Effect.tryPromise({
try: () => db.users.findOne(id),
catch: (e) => new DbError(e),
});
if (!row) return yield* Effect.fail(new NotFound(id));
return row;
});
The signature tells you exactly what can go wrong. The compiler enforces handling.
Layers (dependency injection)
import { Context, Layer } from "effect";
class Database extends Context.Tag("Database")<Database, { users: { findOne: ... } }>() {}
const DatabaseLive = Layer.effect(
Database,
Effect.sync(() => ({ users: { findOne: realFindOne } }))
);
const DatabaseTest = Layer.succeed(Database, { users: { findOne: stubFind } });
// Wire and run
const main = findUser("123").pipe(Effect.provide(DatabaseLive));
Effect.runPromise(main);
// Tests use DatabaseTest — no mocking framework
DI without Reflect-metadata, without decorators, without runtime reflection. The compiler resolves wiring.
Concurrency
const program = Effect.all(
[fetchA, fetchB, fetchC],
{ concurrency: "unbounded" }
);
const program = Effect.all(items.map(fetchItem), { concurrency: 10 });
const winner = Effect.race(fetchPrimary, fetchSecondary);
const both = Effect.zip(fetchA, fetchB);
Structured concurrency, fiber-based. Cancellation propagates automatically.
Resource management
const withFile = Effect.acquireUseRelease(
Effect.sync(() => fs.openSync("data.txt", "r")),
(fd) => Effect.sync(() => fs.readFileSync(fd, "utf8")),
(fd) => Effect.sync(() => fs.closeSync(fd))
);
Resource is released even on error / interruption. Like defer/using, but composable.
Schedule (retries)
import { Schedule } from "effect";
const retried = fetchSomething.pipe(
Effect.retry(Schedule.exponential("100 millis").pipe(Schedule.intersect(Schedule.recurs(5))))
);
Composable schedules: exponential, fibonacci, recurs, jittered. Used for retries, polling, background jobs.
When Effect pays off
- Big greenfield TS app with a team willing to invest 1–3 weeks of ramp-up.
- Domain-heavy code where typed errors prevent classes of bugs.
- Complex orchestration: retries, timeouts, fanout, cancellation.
- Testability matters more than usual; DI via Layer is excellent for tests.
When it doesn’t
- Small app or weekend project: cost > benefit.
- Team turnover is high: the curve is real.
- Ecosystem fit: most libraries return Promises; you’ll wrap a lot.
- Hiring market: fewer devs comfortable with Effect than with plain TS.
Plain TS alternatives
For typed errors without Effect:
import { Result, ok, err } from "neverthrow";
const findUser = async (id: string): Promise<Result<User, NotFoundError | DbError>> => {
// ...
};
Get 70% of the typed-error benefit with 5% of the curve. For most teams: this is the right step before Effect.
DI without Effect
class UserService {
constructor(private db: Database, private mailer: Mailer) {}
}
Constructor injection. Tests pass fakes. Boring; works.
Adoption strategy
If you want to try Effect:
- Pick one new feature to build with Effect.
- Wrap external libraries with
Effect.tryPromise. - Use Layer for that feature’s deps.
- Evaluate after 2 weeks: is the team productive? Is debugging tractable?
- Roll back if no. The codebase is fine to mix Effect-modules with non-Effect.
Common mistakes
1. Adopting Effect everywhere on day one
Big-bang rewrites fail. Adopt one boundary at a time.
2. Mixing Effect with raw Promises
Half-Effect-half-Promise code is hard to reason about. Either Effect-all-the-way for a module or don’t.
3. Underestimating the curve
Two weeks is realistic for a senior TS dev. Don’t promise productivity gains in week 1.
4. Ignoring Effect.runFork vs Effect.runPromise
runPromise blocks the caller; runFork returns a fiber. Pick deliberately.
5. Treating Effect as a runtime, not a library
Effect doesn’t replace your framework. It composes inside it. Express, Hono, etc. still own HTTP.
Read this next
If you want my Effect-TS starter (typed errors, Layer DI, schedules), it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .