Skip to main content

TS Ep 64: Error Handling in Effect

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 64: This Article
The 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 E channel, handled via catch) and Unexpected Defects (crashes, panics, out of memory).
  • Discriminated Errors: Always use custom error classes built with Data.TaggedError or 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.match or Effect.catchAll at 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.

  1. 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.
  2. Unexpected Defects: These are fatal crashes. A severed database cable, an Out of Memory error, a null pointer 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
#

MethodBehaviorWhen to use
Effect.catchTagCatches a single specific error by its _tag.When you want to recover from one specific failure (e.g., TokenExpired) but let others crash.
Effect.catchTagsCatches multiple specific errors by their _tags.When handling a specific subset of domain errors.
Effect.catchAllCatches absolutely everything in the E channel.At the edge of the system to prevent fatal crashes.
Effect.matchFolds 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/catch blocks with functional error channels.
  • We differentiated between Expected Failures (E) and Unexpected Defects.
  • We used Data.TaggedError to 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!

typescript - This article is part of a series.
Part 64: This Article