Declaring failures as types instead of try-catch changes how you write service tests.
When writing NestJS services, there's always that moment: you call findUser and wonder "what can this throw?" — you open the signature and see Promise<User>. That's it. To find out what exceptions might actually occur, you have to read the implementation directly, and to mock the DB in tests, you wrestle with jest.mock().
When you're handling 6 failure cases in payment logic with try-catch, a natural question arises: if failure cases aren't in the type signature, unhandled errors hide until runtime. Even though TypeScript is a strongly-typed language, throw is not subject to type checking.
This article explores how Effect-TS v3's three-channel type system approaches that problem differently. This is based on the Effect v3 API; as of June 2026, a v4 beta is also available, but the core concepts carry over.
The Three Channels of the Effect Type
Understanding what each of the three parameters in Effect<A, E, R> means makes everything that follows much easier.
| Channel | Meaning | When never |
|---|---|---|
| A (Success) | Return type on success | — |
| E (Error) | List of expected failure types | No domain failures |
| R (Requirements) | List of services required for execution | No dependencies |
// Promise — can't tell from the signature what it might throw
async function findUser(id: string): Promise<User> { ... }
// Effect — failures and dependencies are declared in the signature
const findUser = (id: string): Effect.Effect<User, UserNotFound | DbError, Database> => ...The Tagged Error Pattern
Declaring errors with Data.TaggedError adds a _tag discriminant field, allowing catchTag to catch only specific errors precisely.
import { Data, Effect } from "effect";
class UserNotFound extends Data.TaggedError("UserNotFound")<{
id: string;
}> {}
class DbError extends Data.TaggedError("DbError")<{
cause: unknown;
}> {}const program = Effect.gen(function* () {
const user = yield* findUser("123");
// E channel: UserNotFound | DbError automatically composed
return user;
}).pipe(
// Handle UserNotFound locally → removed from E channel
Effect.catchTag("UserNotFound", (e) =>
Effect.succeed({ id: e.id, name: "Guest" })
)
// DbError remains in E channel → caller must handle it
);
// Type: Effect<User, DbError, Database>How the E Channel Narrows
Each time you chain catchTag, the corresponding error type is removed from the E channel. The diagram below shows the actual type transformation process.
The point at which provide(DatabaseLive) achieves R = never is the compile-time condition for calling runPromise. E = never is independently a runtime guarantee that "this Effect will never reject."
Layer-Based Dependency Injection
Layer<ROut, E, RIn> is a recipe for creating a service. Context.GenericTag creates an injection token for the service type, and a Layer binds an implementation to that token.
Comparing it to NestJS's @Inject('DATABASE') makes this easier to understand. NestJS manages string tokens and types separately, but Context.GenericTag<DatabaseService>("Database") binds the service type DatabaseService and the runtime identifier "Database" into a single tag for type-safe usage.
import { Context, Effect, Layer } from "effect";
interface DatabaseService {
query: (sql: string, params: unknown[]) => Effect.Effect<unknown[], DbError>;
}
// "Database" is the runtime identifier, DatabaseService is the static type
const Database = Context.GenericTag<DatabaseService>("Database");
const DatabaseLive = Layer.succeed(Database, {
query: (sql, params) =>
Effect.tryPromise({
try: () => pool.query(sql, params).then((r) => r.rows),
catch: (e) => new DbError({ cause: e }),
}),
});Swapping production and test implementations is as simple as changing Effect.provide(layer).
Practical Application
Introducing Effect into the NestJS Service Layer
When adopting Effect in NestJS, the most natural entry point is a pattern where service methods return Effects and a global interceptor handles runPromise.
Clarifying the separation of roles between the two DI systems prevents confusion. The NestJS container is responsible for creating UserService instances, while Effect's Layer system handles the Database dependency. Since UserService has no constructor parameters, there's no need to register Database with NestJS DI separately. The Database is only connected when Effect.provide(DatabaseLive) is called in the interceptor.
// user.service.ts
import { Effect } from "effect";
// In a real project, it's recommended to validate with Effect Schema or Zod
const parseUser = (row: unknown): User => {
const r = row as Record<string, unknown>;
return {
id: r["id"] as string,
name: r["name"] as string,
email: r["email"] as string,
};
};
@Injectable()
export class UserService {
findOne(id: string): Effect.Effect<User, UserNotFound | DbError, Database> {
return Effect.gen(function* () {
const db = yield* Database;
// db.query has DbError in its E channel, so it propagates automatically via yield*
const rows = yield* db.query(`SELECT * FROM users WHERE id = $1`, [id]);
if (rows.length === 0) {
return yield* new UserNotFound({ id });
}
return parseUser(rows[0]);
});
}
}// effect.interceptor.ts — global interceptor
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from "@nestjs/common";
import { Effect } from "effect";
import { Observable, from, of, switchMap } from "rxjs";
@Injectable()
export class EffectInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
return next.handle().pipe(
switchMap((value) => {
if (Effect.isEffect(value)) {
return from(
Effect.runPromise(
value.pipe(Effect.provide(DatabaseLive))
)
);
}
return of(value);
})
);
}
}Swapping Test Layers — Without a Real DB
No jest.mock() or jest.spyOn() — just swap the Layer.
It's worth noting why Effect.runPromiseExit is used here. runPromise rejects the Promise if any errors remain in the E channel, so tests would need expect(...).rejects. runPromiseExit returns both success and failure as an Exit<A, E> value, making it much more convenient when you want to inspect error types directly.
// user.service.spec.ts
import { Effect, Exit, Cause, Layer } from "effect";
const TestDatabase = Layer.succeed(Database, {
query: (_sql, _params) =>
Effect.succeed([{ id: "123", name: "Alice", email: "alice@example.com" }]),
});
describe("UserService.findOne", () => {
it("returns a User when the user exists", async () => {
const result = await Effect.runPromise(
userService.findOne("123").pipe(Effect.provide(TestDatabase))
);
expect(result.name).toBe("Alice");
});
it("raises a UserNotFound error when the user does not exist", async () => {
const EmptyDatabase = Layer.succeed(Database, {
query: () => Effect.succeed([]),
});
// runPromiseExit: returns both success and failure as Exit<A, E> (no reject)
const exit = await Effect.runPromiseExit(
userService.findOne("999").pipe(Effect.provide(EmptyDatabase))
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit) && Cause.isFail(exit.cause)) {
expect(exit.cause.error).toBeInstanceOf(UserNotFound);
expect((exit.cause.error as UserNotFound).id).toBe("999");
}
});
});Closing Error Handling by Type in a Hono Handler
// user.handler.ts (Hono)
import { Effect } from "effect";
app.get("/users/:id", async (c) => {
const handler = Effect.gen(function* () {
const user = yield* userService.findOne(c.req.param("id"));
return c.json(user);
}).pipe(
// E: UserNotFound | DbError → DbError
Effect.catchTag("UserNotFound", (e) =>
Effect.succeed(c.json({ error: `User ${e.id} not found` }, 404))
),
// E: DbError → never
Effect.catchTag("DbError", (e) => {
console.error(e.cause);
return Effect.succeed(c.json({ error: "Internal server error" }, 500));
}),
// R: Database → never (satisfies runPromise compile condition)
Effect.provide(DatabaseLive)
);
return Effect.runPromise(handler);
});Retry Patterns in an AI Agent Pipeline
Effect's Schedule combinators let you apply different retry policies per error type.
import { Effect, Schedule, Data } from "effect";
class RateLimitError extends Data.TaggedError("RateLimitError")<{}> {}
class ModelUnavailableError extends Data.TaggedError("ModelUnavailableError")<{}> {}
// A schedule that retries only on RateLimitError
const rateLimitSchedule = Schedule.exponential("1 second").pipe(
Schedule.intersect(Schedule.recurs(3)),
Schedule.whileInput(
(e: RateLimitError | ModelUnavailableError) => e._tag === "RateLimitError"
)
);
const callLLM = (prompt: string) =>
Effect.gen(function* () {
// ... gpt-4o call
}).pipe(
// RateLimit: retry up to 3 times with exponential backoff
// ModelUnavailableError: does not meet schedule condition → passed to catchTag below
Effect.retry(rateLimitSchedule),
// If ModelUnavailable, fall back to fallback model
Effect.catchTag("ModelUnavailableError", () => callFallbackLLM(prompt))
);Pros and Cons
Pros
| Item | Detail |
|---|---|
| Type signature transparency | E channel declares all possible failures — you can know what might fail from the signature alone, without reading the implementation |
| Exhaustive error handling | E channel narrows with each catchTag and the compiler tracks remaining types — missed handling is caught immediately |
| Testability | All dependencies swappable by replacing a Layer — unit tests without a real DB or network |
| Error semantic separation | Explicit distinction between defects (bugs) vs failures (domain errors) |
| Built-in tooling | Retry, Schedule, OpenTelemetry, Fiber — no separate libraries needed |
| Procedural style | Effect.gen + yield* lets you write code just like async/await |
Cons
| Item | Detail |
|---|---|
| Learning curve | 2–4 weeks to understand Effect<A, E, R> and the Layer system |
| Bundle size | Including the full Effect runtime significantly increases bundle size — tree-shaking strategy needed for edge environments |
| Friction with existing code | Mixing with Promise-based code increases Effect.tryPromise / Effect.runPromise conversion points |
| Risk of over-engineering | Applying Effect to simple CRUD actually increases complexity |
| No official adapters | No official NestJS or Hono support — integration code must be written by hand |
| Fast-moving ecosystem | Major changes like v3 → v4 happen quickly, increasing upgrade burden |
Common Mistakes in Practice
1. The urge to introduce Effect into every service
Applying Effect to simple CRUD endpoints just adds code volume. It's best to start with layers that have 3 or more failure modes or frequently need dependency swapping — payments, external API integrations, and AI orchestration are prime candidates.
2. Calling Effect.runPromise inside a service
Calling runPromise inside a service method causes you to escape the Effect world. Error composition breaks, and Layer swapping in tests becomes impossible. It's best to only call runPromise at the topmost boundary (interceptors, handler wrappers).
3. Skipping the comparison with neverthrow
If your team isn't familiar with functional programming, it's a good idea to first experience the Result<T, E> pattern from neverthrow. The learning curve is gentler and integration with existing code is easier. However, DI, concurrency, and retry must be solved manually, so you can consider switching to Effect when complexity rises.
Tool Selection Decision Flow
Closing Thoughts
If you want to get started, here's the recommended order.
Step 1 — Create one Tagged Error
In one method of an existing service, replace throw new Error("not found") with Data.TaggedError("UserNotFound") and declare the return type as Effect<User, UserNotFound>.
Step 2 — Experience swapping a test Layer
Write one test that replaces jest.mock() with Layer.succeed(Database, { query: () => Effect.succeed([...]) }). The experience of changing only the dependency without touching the same service code is the fastest way to intuitively understand the Layer system.
Step 3 — Set up the interceptor boundary
For NestJS, create a thin boundary in a global interceptor; for Hono, in middleware — calling Effect.runPromise there — and gradually expand so the entire service layer returns Effects.
Now that the Effect v4 beta is out, you might hesitate to start a new codebase on v3. The v3 → v4 migration is primarily about package consolidation and minor API changes; core concepts like Effect<A, E, R>, Layer, and Tagged Error carry over unchanged. Get the concepts down first, and the version transition cost won't be large.
References
- Effect Official Docs — Expected Errors
- Effect Official Docs — Yieldable Errors
- Effect Official Docs — Managing Layers
- Effect 3.0 Release Notes
- Effect v4 Beta Announcement
- Effect vs neverthrow Official Comparison
- Building a backend with Effect-TS: 6 patterns from Inkpipe
- Effect with NestJS — Medium
- Intro To Effect, Part 2: Handling Errors
- No more Try/Catch: a better way to handle errors in TypeScript — DEV Community
- TypeScript Errors and Effect — davidmyno.rs
- Effect-TS in 2026: FP for TypeScript That Actually Makes Sense — DEV
- Why We Love FP but Don't Use Effect-TS — Harbor
- EffectPatterns GitHub — Community Pattern Collection
- Try-Catch is a Lie: Why TypeScript Error Handling is Broken