Effect-TS: Stop Writing Fragile TypeScript and Start Using Typed Effects

You’ve written the TypeScript. The types compile. The tests pass. And then production blows up because some database call threw unknown, your error handler caught it as any, and somewhere in that chain a critical side effect silently swallowed the failure.

This is the TypeScript tax nobody talks about — the gap between "it compiles" and "it actually works." Errors are untyped. Dependencies are implicit. Side effects are invisible. You encode all this domain complexity into your types, and then throw it all away the moment you open a database connection or call a third-party API.

Effect-TS closes that gap. It’s not another Promise wrapper or a lightweight "functional utilities" library. It’s a complete runtime for building programs where errors, dependencies, and side effects are all first-class typed citizens — just like your data.

The official GitHub repo is at https://github.com/Effect-TS/effect. The ecosystem is large and the docs are comprehensive, but getting started without drowning requires understanding the core model first.

The Core Mental Model

An Effect<A, E, R> is a description of a program that:

  • produces a value of type A on success
  • fails with a typed error of type E on failure
  • requires dependencies of type R to run

That third parameter is what makes Effect genuinely different from libraries like fp-ts or neverthrow. Your dependencies — database connections, HTTP clients, config, loggers — are encoded in the type signature. If you forget to provide a dependency, it won’t compile. Not a runtime error. A compile error.

Compare this to the typical Node.js pattern:

// The classic lie: "I have no dependencies, I promise"
async function getUser(id: string): Promise<User> {
  const db = getGlobalDatabase(); // where does this come from? who knows
  const result = await db.query(`SELECT * FROM users WHERE id = $1`, [id]);
  if (!result.rows[0]) throw new Error("User not found"); // Error type: unknown
  return result.rows[0];
}

With Effect:

import { Effect } from "effect";

// Honest about what it needs and what can go wrong
const getUser = (id: string): Effect.Effect<User, UserNotFound, Database> =>
  Effect.gen(function* () {
    const db = yield* Database;
    const row = yield* db.queryOne(`SELECT * FROM users WHERE id = $1`, [id]);
    return row;
  });

The compiler enforces that you provide Database before running this effect. The caller knows it can fail with UserNotFound. No surprises.

Setting Up

Install the core package:

npm install effect

That’s it. The effect package is the monorepo core and includes everything: the runtime, the standard library, streams, scheduling, and more. There are additional packages for specific integrations (@effect/platform, @effect/sql, @effect/rpc) but you don’t need them to start.

TypeScript config worth having:

{
  "compilerOptions": {
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "target": "ES2022",
    "moduleResolution": "bundler"
  }
}

Effect uses exactOptionalPropertyTypes in its own codebase and it’s worth enabling — it catches a whole class of subtle bugs around optional fields.

Typed Errors: The Feature You Actually Needed

The single biggest win from Effect in a production codebase is typed errors. Not typed as Error, not typed as unknown — typed as your actual domain error variants.

import { Effect, Data } from "effect";

// Define your error types using Data.TaggedError
class DatabaseConnectionError extends Data.TaggedError("DatabaseConnectionError")<{
  readonly cause: unknown;
  readonly host: string;
}> {}

class RecordNotFound extends Data.TaggedError("RecordNotFound")<{
  readonly table: string;
  readonly id: string;
}> {}

class ValidationError extends Data.TaggedError("ValidationError")<{
  readonly field: string;
  readonly message: string;
}> {}

Data.TaggedError gives you structural equality, a discriminant tag for exhaustive pattern matching, and clean serialization. These aren’t just type aliases — they’re real classes with proper prototype chains.

Now write a function that can fail with multiple typed errors:

const fetchAndValidateUser = (id: string): Effect.Effect<
  User,
  RecordNotFound | ValidationError | DatabaseConnectionError,
  Database
> =>
  Effect.gen(function* () {
    const db = yield* Database;

    const row = yield* Effect.tryPromise({
      try: () => db.findUser(id),
      catch: (e) =>
        e instanceof NotFoundError
          ? new RecordNotFound({ table: "users", id })
          : new DatabaseConnectionError({ cause: e, host: db.host }),
    });

    if (!isValidUser(row)) {
      return yield* Effect.fail(
        new ValidationError({ field: "email", message: "Invalid format" })
      );
    }

    return row;
  });

When you call this function, TypeScript forces you to handle all three error cases. Not catch (e: unknown) and hope for the best. Actual exhaustive handling:

const result = yield* fetchAndValidateUser(userId).pipe(
  Effect.catchTag("RecordNotFound", (e) =>
    Effect.succeed({ type: "not_found" as const, id: e.id })
  ),
  Effect.catchTag("ValidationError", (e) =>
    Effect.fail(new HttpError({ status: 400, message: e.message }))
  ),
  // DatabaseConnectionError propagates up — caller decides how to handle it
);

Dependency Injection Without Magic

Effect’s DI model is explicit and entirely type-driven. No decorators, no IoC containers, no reflection. Just types.

Define a service with a Context.Tag:

import { Context, Effect, Layer } from "effect";

// Define the service interface
interface DatabaseService {
  readonly findUser: (id: string) => Promise<UserRow>;
  readonly host: string;
}

// Create the tag — this is the identifier in the dependency graph
class Database extends Context.Tag("Database")<
  Database,
  DatabaseService
>() {}

Implement it with a Layer:

import { Pool } from "pg";

// Production implementation
const DatabaseLive = Layer.effect(
  Database,
  Effect.gen(function* () {
    const config = yield* Config; // another dependency, also typed
    const pool = new Pool({ connectionString: config.databaseUrl });

    return {
      host: config.databaseUrl,
      findUser: (id) =>
        pool.query("SELECT * FROM users WHERE id = $1", [id]).then((r) => r.rows[0]),
    };
  })
);

// Test implementation
const DatabaseTest = Layer.succeed(Database, {
  host: "test",
  findUser: async (id) => ({ id, email: "[email protected]", name: "Test User" }),
});

Run with dependencies provided:

// In production
const program = fetchAndValidateUser("123");
const result = await Effect.runPromise(
  program.pipe(Effect.provide(DatabaseLive))
);

// In tests — swap the layer, no mocking framework needed
const result = await Effect.runPromise(
  program.pipe(Effect.provide(DatabaseTest))
);

The compile error you get when you forget to provide Database:

Type 'Effect<User, RecordNotFound | ValidationError | DatabaseConnectionError, Database>'
is not assignable to type 'Effect<User, ..., never>'.
  Type 'Database' is not assignable to type 'never'.

That’s the compiler telling you to wire up your dependencies. No runtime surprise, no Cannot read properties of undefined.

Gotcha: Don’t Reach for Effect.gen Everywhere

Effect.gen with yield* is ergonomic for complex flows, but for simple transformations you’re paying a cost in readability for no gain. Prefer the pipe-based API for short chains:

// Overkill
const doubled = Effect.gen(function* () {
  const n = yield* Effect.succeed(5);
  return n * 2;
});

// Just use map
const doubled = Effect.succeed(5).pipe(Effect.map((n) => n * 2));

The rule of thumb: if you need more than two yield* statements, Effect.gen is probably cleaner. Otherwise, pipe.

Structured Concurrency That Doesn’t Betray You

Effect runs on fibers — lightweight green threads. Every Effect runs inside a fiber, fibers can be forked, and the parent fiber owns its children. When a parent is interrupted, all its children are interrupted too. No zombie tasks, no resource leaks.

import { Effect, Fiber } from "effect";

const parallelFetch = Effect.gen(function* () {
  // Fork two effects as concurrent fibers
  const userFiber = yield* Effect.fork(fetchUser("123"));
  const ordersFiber = yield* Effect.fork(fetchOrders("123"));

  // Join them — waits for both, fails if either fails
  const user = yield* Fiber.join(userFiber);
  const orders = yield* Fiber.join(ordersFiber);

  return { user, orders };
});

For the common "run N effects in parallel" pattern:

// Fail fast — if any fails, cancel the rest immediately
const results = yield* Effect.all(
  [fetchUser("1"), fetchUser("2"), fetchUser("3")],
  { concurrency: "unbounded" }
);

// Or with bounded concurrency — never spawn more than 5 at once
const results = yield* Effect.all(userIds.map(fetchUser), {
  concurrency: 5,
});

Gotcha: Effect.all Collects Errors by Default in Some Modes

When using mode: "validate", Effect collects all errors rather than failing on the first. This is great for form validation but can catch you off guard if you’re expecting fail-fast behavior:

// This validates ALL users and returns ALL errors, not just the first
const validated = yield* Effect.all(
  [validateUser(a), validateUser(b), validateUser(c)],
  { mode: "validate" }
);

The default mode: "default" is fail-fast. Know which you need before reaching for "validate".

Resource Management with Scope

The most underrated feature in Effect. Managing resources — database connections, file handles, network sockets — is a solved problem with Effect.acquireRelease:

import { Effect, Scope } from "effect";

const managedConnection = Effect.acquireRelease(
  // Acquire
  Effect.tryPromise({
    try: () => pool.connect(),
    catch: (e) => new DatabaseConnectionError({ cause: e, host: pool.options.host }),
  }),
  // Release — always runs, even if the effect using this connection fails
  (connection) => Effect.promise(() => connection.release())
);

// Use the managed resource
const withConnection = Effect.scoped(
  Effect.gen(function* () {
    const connection = yield* managedConnection;
    return yield* runQuery(connection, "SELECT 1");
  })
);

Effect.scoped creates a scope that finalizes all resources when the scope closes — whether the effect succeeded, failed, or was interrupted. This is deterministic resource cleanup without try/finally everywhere.

Building an HTTP Handler: A Real Example

Here’s what an actual production HTTP handler looks like end-to-end:

import { Effect, Layer, Context, Data } from "effect";
import { NodeRuntime } from "@effect/platform-node";

// --- Errors ---
class UserNotFound extends Data.TaggedError("UserNotFound")<{
  readonly id: string;
}> {}

class UnauthorizedError extends Data.TaggedError("UnauthorizedError")<{}> {}

// --- Services ---
class UserRepo extends Context.Tag("UserRepo")<
  UserRepo,
  {
    findById: (id: string) => Effect.Effect<User, UserNotFound>
    updateEmail: (id: string, email: string) => Effect.Effect<void, UserNotFound>
  }
>() {}

class AuthService extends Context.Tag("AuthService")<
  AuthService,
  { verify: (token: string) => Effect.Effect<{ userId: string }, UnauthorizedError> }
>() {}

// --- Handler ---
const updateEmailHandler = (
  token: string,
  targetUserId: string,
  newEmail: string
): Effect.Effect<
  { success: true },
  UserNotFound | UnauthorizedError | ValidationError,
  UserRepo | AuthService
> =>
  Effect.gen(function* () {
    const auth = yield* AuthService;
    const repo = yield* UserRepo;

    // Verify token — fails with UnauthorizedError if invalid
    const { userId } = yield* auth.verify(token);

    // Business rule: you can only update your own email
    if (userId !== targetUserId) {
      return yield* Effect.fail(new UnauthorizedError());
    }

    if (!newEmail.includes("@")) {
      return yield* Effect.fail(
        new ValidationError({ field: "email", message: "Must contain @" })
      );
    }

    yield* repo.updateEmail(targetUserId, newEmail);
    return { success: true };
  });

// --- Express integration ---
app.put("/users/:id/email", async (req, res) => {
  const program = updateEmailHandler(
    req.headers.authorization ?? "",
    req.params.id,
    req.body.email
  ).pipe(
    Effect.catchTag("UserNotFound", () =>
      Effect.succeed(res.status(404).json({ error: "User not found" }))
    ),
    Effect.catchTag("UnauthorizedError", () =>
      Effect.succeed(res.status(403).json({ error: "Forbidden" }))
    ),
    Effect.catchTag("ValidationError", (e) =>
      Effect.succeed(res.status(400).json({ error: e.message }))
    ),
    Effect.provide(Layer.merge(UserRepoLive, AuthServiceLive))
  );

  await Effect.runPromise(program);
});

Every error is handled. Every dependency is explicit. The compiler won’t let you ship this handler without wiring up both UserRepo and AuthService.

Gotcha: The Learning Curve Is Real

Effect-TS has a steep on-ramp. The first week you’ll fight the type system, misread error messages, and wonder if this is worth it. The friction is especially high if your mental model is anchored to async/await.

The inflection point typically comes around week two or three, when you start catching bugs at compile time that would have been production incidents. After that it’s hard to go back.

Don’t try to rewrite an existing codebase all at once. Effect plays well with existing Promise-based code via Effect.promise and Effect.tryPromise. Start at the edges of your domain — one service, one handler — and expand from there.

Gotcha: Effect Is Not Lazy in the Way You Might Expect

Effects are lazy descriptions of programs — they don’t run until you call Effect.runPromise (or a similar runner). But this means you need to be deliberate about when you execute side effects.

// This does NOT call the database yet
const query = fetchUser("123");

// This ALSO does NOT call the database — you just composed two effects
const enriched = query.pipe(Effect.flatMap(enrichWithOrders));

// THIS calls the database — once, at the edge of your program
const result = await Effect.runPromise(
  enriched.pipe(Effect.provide(LiveLayer))
);

Keep your Effect.run* calls at the top of the call stack — in your HTTP handler, your CLI entry point, your Lambda handler. Inside the Effect world, stay in Effect world.

Production-Ready: Logging and Observability

Effect ships with a structured logging system that integrates with the fiber runtime:

import { Effect, Logger, LogLevel } from "effect";

const program = Effect.gen(function* () {
  yield* Effect.log("Starting user sync");
  yield* Effect.logDebug("Fetching from upstream");

  const users = yield* fetchUsers();

  yield* Effect.log(`Fetched ${users.length} users`);
  return users;
});

// Configure minimum log level at the layer level
const withLogging = program.pipe(
  Logger.withMinimumLogLevel(LogLevel.Debug)
);

For production, swap the default logger for a structured JSON logger:

import { Logger } from "effect";

const JsonLogger = Logger.make(({ level, message, date, annotations }) => {
  console.log(JSON.stringify({
    ts: date.toISOString(),
    level: level.label,
    msg: typeof message === "string" ? message : String(message),
    ...Object.fromEntries(annotations),
  }));
});

const program = mainEffect.pipe(Logger.replace(Logger.defaultLogger, JsonLogger));

Annotations attach contextual data to all log calls within a scope — great for request IDs, tenant IDs, trace IDs:

const handleRequest = (requestId: string) =>
  Effect.gen(function* () {
    yield* Effect.log("Request started"); // will include requestId
    yield* processRequest();
    yield* Effect.log("Request complete"); // will also include requestId
  }).pipe(
    Effect.annotateLogs("requestId", requestId)
  );

When Effect-TS Is the Wrong Tool

Not every TypeScript project needs this. Effect makes sense when:

  • Your domain has meaningful error variants that callers need to handle differently
  • You have multiple services with complex dependency graphs
  • You’re building long-running services with concurrency and resource management requirements
  • Your team is comfortable with functional patterns and willing to invest in the learning curve

It’s probably overkill for:

  • Simple CRUD APIs with thin domain logic
  • Scripts and one-off tools
  • Projects where the team has no functional programming background and no appetite to build it

Effect is powerful precisely because it’s opinionated. That opinion has a cost. Make sure the cost is worth it for your specific context.

Where to Go From Here

The official Effect documentation has improved dramatically over the last year and is worth reading front to back. The Effect Discord is active and the maintainers respond quickly.

For the SQL integration, @effect/sql gives you the same typed error and dependency model for database queries. @effect/platform covers HTTP clients and servers. If you’re on Node.js, @effect/platform-node is the runtime adapter.

The ecosystem is moving fast. The API stabilized significantly around version 3.x — if you read older Effect-TS tutorials (pre-3.0), some patterns have changed substantially. Stick to the official docs.

The gap between "TypeScript compiles" and "the program actually works" has always been filled by runtime failures, defensive coding, and luck. Effect closes it with types. It’s not a silver bullet — it’s a precision tool that, once you understand it, changes how you think about designing TypeScript systems.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646