Skip to content

Web Development · TypeScript

Effect TS: Typed Error Handling for TypeScript

TypeScript leaves errors untyped by default. Effect TS fixes that with typed failures, dependency injection, and structured concurrency.

Anurag Verma

Anurag Verma

7 min read

Effect's three-channel type: Value, Error, and Requirements composed through pipe

Sponsored

Share

TypeScript adds types to JavaScript values but leaves one major blind spot: errors. A function that throws doesn’t communicate what it throws in its type signature. A Promise that rejects doesn’t tell callers what kind of rejection to expect. You write TypeScript to eliminate surprises, and then the error path is a complete surprise every time.

Effect TS addresses this at the type level. It’s a library that models effects (things that can fail, need dependencies, run asynchronously), as typed values you compose rather than side effects that happen around your code.

This is a different programming model from standard TypeScript, and it runs alongside the type-checking improvements in TypeScript 7’s Go-based compiler rather than replacing any of them. The payoff is substantial for the right codebase. Here is what it does and whether it fits yours.

The core problem with TypeScript’s error handling

TypeScript adds types to values but not to failures. A function that throws doesn’t say what it throws in its signature, a rejected Promise gives the catch block an unknown, and callers who skip the try/catch let the error propagate with no compile-time trace of where it came from or what it means. Here’s what that looks like in practice:

// What does this throw? The type signature doesn't say.
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`)
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
  return response.json()
}

// The caller has no type-level information about what might go wrong
try {
  const user = await fetchUser('123')
} catch (error) {
  // error is `unknown` — you have to cast or check manually
  if (error instanceof Error) {
    console.error(error.message)
  }
}

The error path is untyped. The catch block gets unknown. Callers who skip the try/catch let the error propagate silently. In a chain of async calls, the error origin is often ambiguous.

The Effect Type

Effect models computations as Effect<Value, Error, Requirements>:

  • Value, what the computation produces when it succeeds
  • Error, the union of all typed errors it can fail with
  • Requirements, services the computation needs from its environment (dependency injection)
import { Effect } from 'effect'

function fetchUser(id: string): Effect.Effect<User, NetworkError | NotFoundError, never> {
  return Effect.tryPromise({
    try: async () => {
      const response = await fetch(`/api/users/${id}`)
      if (response.status === 404) throw new NotFoundError(id)
      if (!response.ok) throw new NetworkError(response.status)
      return response.json() as User
    },
    catch: (error) => {
      if (error instanceof NotFoundError) return error
      if (error instanceof NetworkError) return error
      return new NetworkError(500)
    },
  })
}

Now the compiler knows this computation can fail with NetworkError | NotFoundError. Callers who compose with this effect see those errors in the type and have to handle them explicitly.

Error Classes

Define errors as classes so they’re distinguishable at the type level:

class NetworkError {
  readonly _tag = 'NetworkError'
  constructor(readonly statusCode: number) {}
}

class NotFoundError {
  readonly _tag = 'NotFoundError'
  constructor(readonly id: string) {}
}

class ValidationError {
  readonly _tag = 'ValidationError'
  constructor(readonly field: string, readonly message: string) {}
}

The _tag field is a discriminant that lets Effect (and TypeScript) narrow the type in error handling branches.

Composing Effects

The power of Effect is composition. pipe chains effects where each step can add to the success type or the error union:

import { Effect, pipe } from 'effect'

function getActiveUserOrders(userId: string) {
  return pipe(
    fetchUser(userId),
    // Errors from fetchUser propagate forward
    Effect.flatMap((user) => {
      if (!user.isActive) {
        return Effect.fail(new InactiveUserError(userId))
      }
      return fetchOrders(user.id)
    }),
    // Return type is now inferred as:
    // Effect<Order[], NetworkError | NotFoundError | InactiveUserError | OrderFetchError, never>
    Effect.flatMap((orders) =>
      Effect.succeed(orders.filter(o => o.status === 'active'))
    ),
  )
}

The return type of getActiveUserOrders is automatically inferred as the union of all errors across the chain. No manual union typing required.

Handling Errors

Effect provides typed error handling that exhausts the error union:

const result = pipe(
  getActiveUserOrders(userId),

  Effect.catchTag('NotFoundError', (_error) =>
    // _error is narrowed to NotFoundError here
    Effect.succeed([])  // Return empty array for missing users
  ),

  Effect.catchTag('InactiveUserError', (error) =>
    Effect.fail(new UserAccessError(`User ${error.userId} is not active`))
  ),

  // After handling NotFoundError and InactiveUserError,
  // remaining errors are NetworkError | OrderFetchError
  Effect.retry({ times: 3 }),
)

After handling NotFoundError and InactiveUserError, the compiler knows only NetworkError | OrderFetchError remain. If you try to handle a tag that doesn’t exist in the error union, TypeScript catches it at compile time.

Dependency Injection via the Requirements Channel

The third type parameter enables compile-time dependency injection:

import { Effect, Context } from 'effect'

// Define a service interface
class Database extends Context.Tag('Database')<
  Database,
  { query: (sql: string) => Promise<unknown[]> }
>() {}

// This function requires Database — visible in the type signature
function getUsers(): Effect.Effect<User[], DatabaseError, Database> {
  return Effect.flatMap(Database, (db) =>
    Effect.tryPromise({
      try: () => db.query('SELECT * FROM users') as Promise<User[]>,
      catch: () => new DatabaseError(),
    })
  )
}

// Provide the real implementation at the program boundary
const runnable = pipe(
  getUsers(),
  Effect.provideService(Database, {
    query: (sql) => pool.query(sql).then(r => r.rows),
  })
)

// Now Requirements is `never` — the effect can run
Effect.runPromise(runnable)

In tests, swap in a mock without patching imports or mocking frameworks:

const testRunnable = pipe(
  getUsers(),
  Effect.provideService(Database, {
    query: () => Promise.resolve([{ id: '1', name: 'Alice' }]),
  })
)

The dependency is injected through the type system. The test fails at compile time if you forget to provide a required service.

What Else Effect Includes

Effect isn’t just typed errors. The library includes:

Structured concurrency: Effect.all runs effects in parallel with automatic cancellation if one fails. Effect.race returns the first result. Fibers provide lightweight concurrency without callback nesting.

Resource management: Effect.acquireRelease guarantees cleanup even when effects fail or are interrupted, similar to Python’s with or Rust’s Drop, but composable with the rest of the effect system.

Retries and scheduling: Effect.retry with configurable exponential backoff, Effect.timeout, Effect.race with a deadline, all typed and composable.

Schema: @effect/schema provides runtime validation that integrates with the Effect type system, so parse errors are typed failures in the error channel.

When Effect Is Worth the Learning Curve

Effect has a steep learning curve. The pipe-based style and the three-channel type are unfamiliar to most TypeScript developers. Bringing Effect into a codebase means the team needs to learn it, and partial adoption (some files using Effect, others not), creates friction at the boundaries.

Effect pays off when:

  • Error handling is currently a mess: functions that throw anything, catch blocks that swallow errors, inconsistent error types across modules
  • Dependency injection is complex: services with multiple implementations (test vs production), initialization order matters, cleanup needs to be reliable
  • Concurrency is significant: parallel requests with cancellation, rate limiting, resource pools
  • The codebase is large enough that compile-time guarantees have meaningful return on the learning investment

Effect is probably not worth it for a small API with five endpoints, a CLI tool, or a frontend application. It’s worth serious consideration for backend services that handle complex business logic with multiple failure modes and external service dependencies.

Getting Started Without Rewriting Everything

You don’t need to convert an entire codebase. Effect composes with standard TypeScript at the edges:

// Existing Express handler calling new Effect-based code
async function existingHandler(req: Request, res: Response) {
  try {
    const result = await Effect.runPromise(
      pipe(
        getActiveUserOrders(req.params.userId),
        Effect.catchAll((error) => {
          if (error._tag === 'NotFoundError') return Effect.succeed([])
          return Effect.fail(error)
        })
      )
    )
    res.json(result)
  } catch (error) {
    res.status(500).json({ error: 'Internal server error' })
  }
}

Effect.runPromise bridges back to standard Promises. Introduce Effect in new modules first, handle the conversion at the boundary, and expand as the team gets comfortable with the model.

Who Is Actually Using It

Effect has moved from a niche functional-programming library to production use at a number of backend-heavy TypeScript shops. The community has grown substantially since 2023, and the documentation is now thorough enough that getting started doesn’t require reading academic papers.

The problem it solves, untyped errors in TypeScript, is real and felt by anyone who has debugged a production incident caused by an unhandled promise rejection or a thrown value that wasn’t an Error. TypeScript should have had typed error channels from the start. It didn’t, but Effect gets you there today, and it composes cleanly with schema validation via Zod at the boundaries where external data enters your typed effects.

Frequently asked questions

What problem does Effect TS actually solve?
Untyped errors. In standard TypeScript, a function's type signature says nothing about what it can throw or what a rejected Promise contains, so catch blocks receive `unknown` and callers can silently skip error handling entirely. Effect makes the full set of possible errors part of the function's type, enforced at compile time.
Is Effect TS worth adopting for a small project?
Usually not. Effect's learning curve is steep, and the payoff scales with codebase complexity: multiple failure modes, non-trivial dependency injection, and significant concurrency. A five-endpoint API, a CLI tool, or a frontend app rarely has enough of that complexity to justify the ramp-up cost.
Do I have to rewrite my whole codebase to use Effect?
No. Effect.runPromise bridges an Effect computation back to a standard Promise, so you can introduce Effect in new modules, convert at the boundary with existing Express handlers or similar, and expand adoption gradually as the team gets comfortable with the pipe-based style.
How does Effect handle dependency injection?
Through the third type parameter, Requirements. A function that needs a database service declares that dependency in its return type (Effect<Value, Error, Database>), and the real implementation is only provided at the program's entry point via Effect.provideService. Tests provide a mock the same way, with the compiler catching a missing dependency before the code runs.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored