DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Effect-TS: The TypeScript Error Handling Library That Makes try-catch Obsolete

If you've spent time building production TypeScript applications, you've probably written some variation of this:

async function getUserData(id: string) {
  try {
    const user = await db.users.findUnique({ where: { id } });
    if (!user) throw new Error('User not found');
    const profile = await fetchExternalProfile(user.email);
    return { user, profile };
  } catch (e) {
    // What type is e? Error? unknown? string?
    console.error('Something failed', e);
    throw e;
  }
}
Enter fullscreen mode Exit fullscreen mode

The problem: TypeScript's type system goes completely dark at the boundary of try-catch. You don't know what can throw. You don't know what was thrown. And every caller has to guess.

Effect-TS fixes this at the type level.

What Effect-TS Actually Is

Effect is a library for building programs where:

  • Errors are typed and tracked in the return type
  • Dependencies are explicit and injectable
  • Side effects are controlled and composable
  • Concurrency is structured and cancellable

The core type is Effect<A, E, R> where:

  • A = the success value type
  • E = the union of possible error types
  • R = the required dependencies (context/environment)

Rewriting the Example

import { Effect, Data } from 'effect';

// Define typed error classes
class UserNotFound extends Data.TaggedError('UserNotFound')<{ id: string }> {}
class ProfileFetchError extends Data.TaggedError('ProfileFetchError')<{ email: string; cause: unknown }> {}

// Effect<{ user: User; profile: Profile }, UserNotFound | ProfileFetchError, DatabaseService>
const getUserData = (id: string) =>
  Effect.gen(function* () {
    const db = yield* DatabaseService;

    const user = yield* Effect.tryPromise({
      try: () => db.users.findUnique({ where: { id } }),
      catch: (cause) => new UserNotFound({ id })
    });

    if (!user) return yield* Effect.fail(new UserNotFound({ id }));

    const profile = yield* Effect.tryPromise({
      try: () => fetchExternalProfile(user.email),
      catch: (cause) => new ProfileFetchError({ email: user.email, cause })
    });

    return { user, profile };
  });
Enter fullscreen mode Exit fullscreen mode

Now the compiler knows exactly what can fail. No guessing.

Pattern 1: Typed Error Handling

const result = yield* getUserData(userId).pipe(
  Effect.catchTag('UserNotFound', (err) =>
    Effect.succeed({ user: null, profile: null, notFound: true })
  ),
  Effect.catchTag('ProfileFetchError', (err) => {
    console.warn(`Profile unavailable for ${err.email}`);
    return Effect.succeed({ user: currentUser, profile: null });
  })
);
Enter fullscreen mode Exit fullscreen mode

If you handle all error cases, the error type becomes never. TypeScript enforces exhaustiveness.

Pattern 2: Dependency Injection Without Framework Magic

import { Context, Layer } from 'effect';

// Define the service interface
interface EmailService {
  send: (to: string, subject: string, body: string) => Effect.Effect<void, EmailError>
}

const EmailService = Context.GenericTag<EmailService>('EmailService');

// Production implementation
const ResendEmailService = Layer.succeed(EmailService, {
  send: (to, subject, body) =>
    Effect.tryPromise({
      try: () => resend.emails.send({ from: 'atlas@whoffagents.com', to, subject, html: body }),
      catch: (cause) => new EmailError({ cause })
    })
});

// Test implementation
const MockEmailService = Layer.succeed(EmailService, {
  send: (to, subject, body) => Effect.log(`Mock email to ${to}: ${subject}`)
});

// Your program doesn't change
const sendWelcomeEmail = (user: User) =>
  Effect.gen(function* () {
    const email = yield* EmailService;
    yield* email.send(user.email, 'Welcome!', welcomeTemplate(user));
  });

// Swap implementations at the edge
Effect.runPromise(sendWelcomeEmail(user).pipe(Effect.provide(ResendEmailService)));
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Structured Concurrency

Effect makes parallel execution safe:

// Run in parallel, fail fast if either fails
const [user, permissions] = yield* Effect.all(
  [fetchUser(userId), fetchPermissions(userId)],
  { concurrency: 'unbounded' }
);

// Run with max concurrency
const results = yield* Effect.all(
  userIds.map(fetchUser),
  { concurrency: 5 }
);

// Race — take the first success, cancel the rest
const fastestResult = yield* Effect.race(
  fetchFromPrimary(id),
  fetchFromReplica(id)
);
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Retries and Resilience

import { Schedule } from 'effect';

// Exponential backoff with jitter, max 5 retries
const withRetry = <A, E>(effect: Effect.Effect<A, E>) =>
  Effect.retry(
    effect,
    Schedule.exponential('100 millis').pipe(
      Schedule.jittered,
      Schedule.upTo('30 seconds'),
      Schedule.intersect(Schedule.recurs(5))
    )
  );

const resilientFetch = withRetry(
  Effect.tryPromise({
    try: () => fetch('https://api.example.com/data'),
    catch: (cause) => new NetworkError({ cause })
  })
);
Enter fullscreen mode Exit fullscreen mode

When Effect-TS Is Worth the Learning Curve

Use Effect when:

  • Your error surface is wide and callers need to know what can fail
  • You need testable dependency injection without a framework
  • You're building complex async pipelines with cancellation requirements
  • You want structured concurrency without reaching for a third library

Skip Effect when:

  • Small scripts or utilities where the overhead isn't justified
  • Team has no FP background and you can't invest in onboarding
  • Simple CRUD with a single database call per route

Practical Migration Strategy

You don't need to rewrite everything. Effect composes with existing code:

// Wrap your existing async functions
const wrappedExistingFn = (id: string) =>
  Effect.tryPromise({
    try: () => yourExistingAsyncFunction(id),
    catch: (cause) => new ExistingFnError({ cause })
  });

// Run Effect programs at your app boundaries
app.get('/users/:id', async (req, res) => {
  const result = await Effect.runPromise(
    getUserData(req.params.id).pipe(
      Effect.provide(ProductionLayers),
      Effect.catchAll((err) => Effect.succeed({ error: err._tag }))
    )
  );
  res.json(result);
});
Enter fullscreen mode Exit fullscreen mode

The Real Value Proposition

Effect-TS isn't about making code more functional for its own sake. It's about making the compiler your error documentation. When you change a function that can now throw a new error type, every caller that doesn't handle it becomes a compile error — not a production incident.

For AI agent applications where a single unhandled error can cascade through 10 tool calls, typed error handling pays for itself on the first incident it prevents.


Building AI-native TypeScript applications? The AI SaaS Starter Kit at whoffagents.com ships with typed error handling patterns, structured logging, and a production-ready agent scaffold — so you don't start from scratch.

Top comments (0)