try/catch block is the functional equivalent of a GOTO statement. It obscures control flow and hides errors from the compiler. Effect-TS replaces it with elegant, typed, exhaustive Pattern Matching.TL;DR (Quick Summary)#
- Failures vs Defects: Effect distinguishes between Expected Failures (typed in the
Echannel, handled viacatch) and Unexpected Defects (crashes, panics, out of memory). - Discriminated Errors: Always use custom error classes built with
Data.TaggedErroror custom tags (_tag). This allows TypeScript to track errors individually. - Handling Errors: Use
Effect.catchTag("ErrorName", ...)to intercept specific errors gracefully while letting other errors pass through the pipeline. - Total Pattern Matching: Use
Effect.matchorEffect.catchAllat the absolute edge of your application to ensure no error leaks to the user.
1. Introduction: The Problem with try/catch#
In standard asynchronous TypeScript, error handling is a nightmare of nested try/catch blocks.
// 🔴 The Imperative Nightmare
async function processOrder(orderId: string) {
try {
const user = await fetchUser(orderId);
try {
await chargeCreditCard(user);
} catch (paymentErr) {
// Is this a Network error? A declined card? A null pointer? We don't know!
console.error("Payment failed", paymentErr);
}
} catch (dbErr) {
console.error("Database failed", dbErr);
}
}Because JavaScript allows you to throw anything (throw "apple"), the catch (e) block always types e as unknown or any. You have to rely on brittle instanceof checks to figure out what actually broke.
Effect-TS moves errors out of the shadows and places them directly into the function signature via the E channel in Effect<A, E, R>.
2. Failures vs Defects#
Before we handle errors, we must understand the Effect philosophy of failure.
- Expected Failures (
E): These are business logic errors you expect to happen. A declined credit card, a 404 Not Found, a validation failure. You want to catch these and show a nice UI to the user. - Unexpected Defects: These are fatal crashes. A severed database cable, an Out of Memory error, a
nullpointer exception. You do not want to catch these in business logic; you want the Fiber to crash and a monitoring system (like DataDog or Sentry) to log the stack trace.
Effect only forces you to handle Expected Failures (E).
3. Step-by-Step: Building Discriminated Errors#
To handle multiple types of errors cleanly, we must turn them into Discriminated Unions (as learned in Episode 58). Effect provides a brilliant utility for this called Data.TaggedError.
Step 1: Define the Tagged Errors#
import { Data } from "effect";
// 🟢 Define custom error classes with unique _tag properties
class UserNotFoundError extends Data.TaggedError("UserNotFound")<{
readonly userId: string;
}> {}
class PaymentDeclinedError extends Data.TaggedError("PaymentDeclined")<{
readonly amount: number;
readonly reason: string;
}> {}Step 2: Yield the Errors in the Pipeline#
Now, our services can fail with these exact errors using Effect.fail().
import { Effect } from "effect";
declare const fetchUser: (id: string) => Effect.Effect<User, UserNotFoundError>;
declare const chargeCard: (user: User) => Effect.Effect<Receipt, PaymentDeclinedError>;
// The Compiler calculates E as: UserNotFoundError | PaymentDeclinedError
const checkoutPipeline = Effect.pipe(
fetchUser("123"),
Effect.flatMap(user => chargeCard(user))
);Because we used Data.TaggedError, the compiler knows exactly what can go wrong in checkoutPipeline.
4. Catching Specific Errors (catchTag)#
If you want to handle only one specific error and let the others propagate, use Effect.catchTag.
const resilientPipeline = Effect.pipe(
checkoutPipeline,
// 🟢 We catch ONLY the PaymentDeclinedError.
// The UserNotFoundError passes through untouched!
Effect.catchTag("PaymentDeclined", (error) => {
console.log(`Card declined for $${error.amount}. Retrying with backup card...`);
// Return a fallback Effect
return chargeBackupCard(error);
})
);By providing the "PaymentDeclined" literal, TypeScript provides perfect autocomplete for the error object inside the callback!
5. Exhaustive Handling (catchAll and match)#
At the very edge of your application (e.g., your Express.js Route Handler), you must handle all remaining errors to return a proper HTTP response.
Using catchAll#
const finalProgram = Effect.pipe(
resilientPipeline,
// 🟢 Catch every single error remaining in the 'E' channel
Effect.catchAll((error) => {
// 'error' is strongly typed as UserNotFoundError (since we handled Payment earlier)
return Effect.succeed(`HTTP 404: User ${error.userId} not found`);
})
);Using match (The Ultimate Pattern Matcher)#
If you want to handle both the Success (A) and the Error (E) at the exact same time, use Effect.match. This is incredibly common when responding to an API request.
const apiResponse = Effect.pipe(
resilientPipeline,
Effect.match({
onFailure: (error) => ({ status: 400, body: error._tag }),
onSuccess: (receipt) => ({ status: 200, body: receipt })
})
);6. Error Handling Methods Comparison#
| Method | Behavior | When to use |
|---|---|---|
Effect.catchTag | Catches a single specific error by its _tag. | When you want to recover from one specific failure (e.g., TokenExpired) but let others crash. |
Effect.catchTags | Catches multiple specific errors by their _tags. | When handling a specific subset of domain errors. |
Effect.catchAll | Catches absolutely everything in the E channel. | At the edge of the system to prevent fatal crashes. |
Effect.match | Folds both E and A into a single new value. | When converting the final pipeline result into an HTTP Response object. |
7. Troubleshooting & Common Errors#
Error 1: Over-Catching Defects#
The Mistake: You wrapped a massive block of code in Effect.catchAll, but you accidentally used Effect.tryPromise({ try: ..., catch: () => new Error() }) badly, turning a fatal database disconnect into a normal “Expected Failure”. Your application swallowed the database outage and returned a 200 OK.
The Fix: Never convert fatal system crashes (Defects) into Expected Failures (E). Let Effect.promise crash the fiber so your monitoring tools can see it. Only put business logic errors into the E channel.
Error 2: Type collapse in catchTag#
TS2345: Argument of type '"UnknownError"' is not assignable to parameter of type '"UserNotFound" | "PaymentDeclined"'.The Cause: You attempted to catch a tag ("UnknownError") that the TypeScript compiler knows absolutely does not exist in the pipeline above it.
The Fix: Trust the compiler. If the compiler says the error doesn’t exist, you don’t need to write code to catch it!
Summary & Next Steps#
In this episode:
- We replaced imperative
try/catchblocks with functional error channels. - We differentiated between Expected Failures (
E) and Unexpected Defects. - We used
Data.TaggedErrorto build discriminated error classes. - We intercepted specific errors perfectly using
Effect.catchTag. - We resolved all remaining errors safely at the edge using
Effect.match.
Our pipelines are now purely functional, fully asynchronous, and completely mathematically safe from errors.
But there is one generic parameter we haven’t touched: R (Requirements). How do we actually pass a Database connection deep into a pipeline without using global variables?
In Episode 65: Context & Dependency Injection, we will master the final piece of the Effect puzzle!

