DEV Community

Cover image for Effect.ts vs Plain TypeScript: When the Algebraic Effects Earn Their Keep
Gabriel Anhaia
Gabriel Anhaia

Posted on

Effect.ts vs Plain TypeScript: When the Algebraic Effects Earn Their Keep


A team picks up Effect because the README sells the dream:
typed errors in the function signature, retries as values you
compose, structured concurrency that cancels child work when
the parent dies, and a dependency-injection story that is not
an inversify decorator soup. Two sprints in, the PR
turnaround has doubled. The engineer who pushed for it ends
up answering "what does Effect<A, E, R> mean again?" on
review after review.

The dream is real. The cost is also real. Effect is one of
the most actively developed functional-effects systems in
TypeScript today (the stable line sits in the 3.x series at
the time of writing, with v4 in development — check the
npm page for current).
It earns its keep on a narrow set of problems. On everything
else, plain TypeScript with a small helper or two does the
same job for a tenth of the cognitive load.

Three takes on the same task: plain TS, the fp-ts idiom,
Effect. Then where each belongs.

The task: fetch a user, retry on transient failure, time out at 2s

Concrete enough to compare. The user lookup hits an HTTP
service that occasionally 503s. We want at most three tries,
exponential backoff, a 2-second timeout per attempt, and a
typed error channel so the caller can react to the difference
between "user does not exist" and "the dependency is down."

Plain TypeScript

type FetchError =
  | { kind: "not-found"; id: string }
  | { kind: "timeout"; afterMs: number }
  | { kind: "upstream"; status: number };

type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function fetchUserOnce(
  id: string,
  signal: AbortSignal,
): Promise<Result<User, FetchError>> {
  const res = await fetch(`/users/${id}`, { signal });
  if (res.status === 404) {
    return { ok: false, error: { kind: "not-found", id } };
  }
  if (!res.ok) {
    return {
      ok: false,
      error: { kind: "upstream", status: res.status },
    };
  }
  return { ok: true, value: (await res.json()) as User };
}

async function withTimeout<T>(
  ms: number,
  run: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), ms);
  try {
    return await run(ac.signal);
  } finally {
    clearTimeout(timer);
  }
}

async function fetchUser(
  id: string,
): Promise<Result<User, FetchError>> {
  let lastErr: FetchError = { kind: "timeout", afterMs: 2000 };
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await withTimeout(2000, (signal) =>
        fetchUserOnce(id, signal),
      );
    } catch (e) {
      lastErr = { kind: "timeout", afterMs: 2000 };
      const backoff = 100 * 2 ** attempt;
      await new Promise((r) => setTimeout(r, backoff));
    }
  }
  return { ok: false, error: lastErr };
}
Enter fullscreen mode Exit fullscreen mode

Forty lines. No imports beyond the platform. The error type is
explicit. The retry loop is a for. The timeout is an
AbortController plus a setTimeout. A backend engineer who
has never heard of Effect reads it in one pass.

What it does not give you: cancellation that propagates into
the running fetch when the caller is cancelled, a single
place to compose this with a circuit breaker or a rate limiter
across the codebase, and a type signature that announces the
error channel without a Result<T, E> convention everyone
agrees on.

fp-ts (the older idiom, still in plenty of codebases)

import { pipe } from "fp-ts/function";
import * as TE from "fp-ts/TaskEither";

const fetchUserOnce = (
  id: string,
): TE.TaskEither<FetchError, User> =>
  TE.tryCatch(
    async () => {
      const res = await fetch(`/users/${id}`);
      if (res.status === 404) throw { kind: "not-found", id };
      if (!res.ok) {
        throw { kind: "upstream", status: res.status };
      }
      return (await res.json()) as User;
    },
    (e) => e as FetchError,
  );

const withRetry =
  (times: number) =>
  <E, A>(task: TE.TaskEither<E, A>): TE.TaskEither<E, A> =>
    times <= 0
      ? task
      : pipe(
          task,
          TE.orElse(() => withRetry(times - 1)(task)),
        );

const fetchUser = (id: string) =>
  pipe(fetchUserOnce(id), withRetry(2));
Enter fullscreen mode Exit fullscreen mode

Cleaner on the happy path. The error type rides in the
TaskEither<FetchError, User> signature. The retry helper
composes. What is missing here, and what fp-ts never had a
clean answer for: the timeout. TaskEither has no built-in
notion of cancellation, no scheduler, no fiber. To time out
you go back to AbortController and bolt it on, and the
typed-error story stops at the boundary of the runtime.

This is exactly the gap Effect was built to close.

Effect

import { Effect, Schedule, Duration } from "effect";

class FetchService extends Effect.Service<FetchService>()(
  "FetchService",
  {
    effect: Effect.succeed({
      get: (id: string) =>
        Effect.tryPromise({
          try: async () => {
            const res = await fetch(`/users/${id}`);
            if (res.status === 404) {
              throw { kind: "not-found" as const, id };
            }
            if (!res.ok) {
              throw {
                kind: "upstream" as const,
                status: res.status,
              };
            }
            return (await res.json()) as User;
          },
          catch: (e) => e as FetchError,
        }),
    }),
  },
) {}

const fetchUser = (id: string) =>
  Effect.gen(function* () {
    const svc = yield* FetchService;
    const policy = Schedule.exponential(
      Duration.millis(100),
    ).pipe(Schedule.intersect(Schedule.recurs(2)));
    return yield* svc.get(id).pipe(
      Effect.timeout(Duration.seconds(2)),
      Effect.retry(policy),
    );
  });
Enter fullscreen mode Exit fullscreen mode

(Verify the schedule combinators against your installed
effect version — the package iterates quickly and the exact
shape of Effect.retry policies has shifted across the 3.x
line.)

The function signature carries everything: the success type,
the error union (including the TimeoutException Effect adds
when Effect.timeout fires), and the requirement on
FetchService in the R slot of Effect<A, E, R>. When the
calling fiber is interrupted, the fetch underneath is
interrupted with it — Effect's structured concurrency
propagates cancellation through the fiber tree without a
single AbortController written by hand. Swap Schedule.exponential
for Schedule.fibonacci and the retry shape changes by one
identifier.

The pitch holds. The cost is the question.

Where Effect actually wins

Five places where the cost of learning Effect pays back across
the lifetime of the codebase. Strongest case first.

Structured concurrency with cancellation. The hardest
thing to get right in async TypeScript is cancellation that
propagates. Promise.race does not cancel the loser. An
AbortController works for one fetch and stops being
ergonomic the moment your work crosses a queue, a worker, or
another await. Effect's fibers form a supervision tree —
when a parent fiber is interrupted, every child fiber it
forked is interrupted, and the cleanup runs in finalisers
attached to each scope. Effect.race, Effect.all with
concurrency limits, and Effect.forkScoped give you the
shapes that are tedious to write by hand. Think parallel
fan-out across pricing providers, or a search across multiple
indexes where the first answer wins. That is where Effect
earns its keep.

Typed error channels. A function returning
Effect<User, FetchError | TimeoutException, FetchService>
tells the caller, in the signature, what can go wrong. Add a
new error kind in the body and the compiler walks every call
site. With plain TypeScript you reach the same outcome with
the Result<T, E> discriminated union from earlier — but you
have to apply the convention everywhere by hand. Effect makes
the convention free and the inference tracks it through every
combinator you compose.

Retry, timeout, repeat as values. Schedule.exponential,
Schedule.fibonacci, Schedule.recurs(3).pipe(...),
Effect.timeout, Effect.repeat — all are first-class values
you compose, name, share across services, test against a
TestClock. As of writing, Effect's core does not ship a
packaged circuit-breaker (community packages cover that), but
your own Effect-shaped helper drops in the same way as a
Schedule. The contrast with plain TS is that retry and
timeout become reusable identifiers instead of ad-hoc loops
repeated across services.

Schema-first design (Effect Schema). Decoder, encoder,
JSON Schema, OpenAPI, and arbitrary-data generation for
property tests all derive from one schema definition. Zod's
core is parsing-first and has its own ecosystem for the rest
(zod-to-json-schema, transforms, refinements, codecs in v4).
Effect Schema is built around bidirectional codecs from the
start, with JSON-Schema output, transformations between
representations, and arbitrary-data generation bundled in. The
same value drives runtime validation, generated types, and
test data. For a domain-heavy service that is real ground
covered, with no separate validator-and-types library to keep
in sync.

Dependency injection via Layers. The
Effect<A, E, R> requirement slot is checked at compile time:
if your function uses FetchService, the requirement appears
in R, and the program does not compile until a
Layer.succeed(FetchService, ...) or Layer.effect(...) is
provided at the top of the program. There is no runtime
container, no decorator, no string keys. Tests provide a
Layer.succeed(FetchService, fakeImpl) and the same code
runs against the fake. Compared to inversify, tsyringe, or
a hand-rolled container, the type-driven version makes "did I
forget to bind this?" a compile error.

Where plain TypeScript still wins

And three places where reaching for Effect is the wrong call.

Small services and scripts. A 2,000-line API that fronts
one database and three external services does not need
fibers. The forty-line plain-TS version above is honest about
what it does. A team that has not internalised
Effect<A, E, R> will spend more time writing the Effect
version than the cost of the rare gnarly retry it would have
saved.

Library APIs whose callers should not have to know Effect.
This is the real one. If you publish a package and your public
API returns Effect<User, FetchError, FetchService>, every
consumer of your library has just been opted into Effect.
That is fine inside a monorepo where the whole codebase is
already Effect-shaped. It is not fine for an open-source
package the rest of the ecosystem will pull in. The standard
move is to keep Effect internal and expose Promise-returning
functions at the boundary via Effect.runPromise. Anything
else is a viral runtime dependency on your callers.

Teams new to effect systems. Effect's design is heavily
inspired by ZIO from Scala, and the cognitive load is
comparable. A team that has never written IO, TaskEither,
or ZIO before is going to spend real weeks before code
review stops being a teaching session. If the calendar does not have those
weeks, ship the plain TS version, accept that retries and
timeouts are local helpers, and revisit when the team has the
bandwidth to commit.

The honest middle ground

Most codebases that adopt Effect successfully do it in one of
two shapes. The first is wholesale: a new service, written in
Effect from day one, with the team picking up the model
together. The second, and the more common one in practice, is
a narrow seam — the parts of the system that genuinely need
structured concurrency and typed errors (the orchestration
layer, the retry-heavy integration layer) sit in Effect, and
the rest of the codebase calls into them via a thin
runPromise boundary.

What does not work: scattering Effect.gen blocks through a
plain-TS codebase because one engineer read the docs over a
weekend. The cost of half-Effect is higher than full Effect
and higher than plain TS, because every boundary becomes a
conversion point and the error story leaks at every one.

Effect is a language inside a language. Pick it on purpose, at
a boundary you can defend, and the algebraic-effects pitch is
real. Pick it by accident and the next reviewer pays for it.


If this was useful

The TypeScript ecosystem has more than one effect system,
more than one DI story, and more than one way to model the
error channel. TypeScript in Production covers the
trade-offs between them — what to pick, when to pick it, and
what the migration cost looks like when the choice was wrong.

For the broader set, the five-book TypeScript Library
collection covers the type system itself, the foundations,
production tooling, and the JVM and PHP bridges in one place:
xgabriel.com/the-typescript-library.

The TypeScript Library — the 5-book collection

Top comments (0)