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:

  1. Pick one new feature to build with Effect.
  2. Wrap external libraries with Effect.tryPromise.
  3. Use Layer for that feature’s deps.
  4. Evaluate after 2 weeks: is the team productive? Is debugging tractable?
  5. 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 .