DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Effect-TS: Replace Zod, Result Types, and Async Errors With One Library

TypeScript developers juggle 4–5 libraries to handle what Effect does in one: validation (Zod), error handling (neverthrow/ts-results), retry logic (p-retry), dependency injection (tsyringe), and async concurrency (p-limit). Effect unifies all of them.

Here's what it looks like in practice.

The Problem With the Current Stack

A typical production handler:

// validation
const input = UserSchema.parse(req.body) // throws on error
// async with retry
const user = await retry(async () => db.users.findById(input.id), { retries: 3 })
// result type
const result: Result<User, DbError> = ...
// error boundary
try {
  ...
} catch (e) {
  if (e instanceof ValidationError) { ... }
  else if (e instanceof NetworkError) { ... }
}
Enter fullscreen mode Exit fullscreen mode

Every library has its own error model. They don't compose.

Effect's Core Model

Effect uses a three-type signature: Effect<Success, Error, Requirements>.

import { Effect, Schema } from "effect"

const UserSchema = Schema.Struct({
  id: Schema.String,
  email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/))
})

type User = Schema.Schema.Type<typeof UserSchema>

const getUser = (id: string): Effect.Effect<User, UserNotFoundError | DbError> =>
  Effect.tryPromise({
    try: () => db.users.findById(id),
    catch: (e) => new DbError({ cause: e })
  }).pipe(
    Effect.flatMap(user =>
      user ? Effect.succeed(user) : Effect.fail(new UserNotFoundError({ id }))
    )
  )
Enter fullscreen mode Exit fullscreen mode

The return type tells you exactly what can go wrong. No surprise unknown in catch blocks.

Error Handling That Composes

import { Effect, Data } from "effect"

class UserNotFoundError extends Data.TaggedError("UserNotFoundError")<{ id: string }> {}
class DbError extends Data.TaggedError("DbError")<{ cause: unknown }> {}

const handleRequest = (id: string) =>
  getUser(id).pipe(
    Effect.catchTag("UserNotFoundError", (e) =>
      Effect.succeed({ error: `User ${e.id} not found`, status: 404 })
    ),
    Effect.catchTag("DbError", (e) =>
      Effect.fail(new ServiceUnavailableError({ cause: e.cause }))
    )
  )
Enter fullscreen mode Exit fullscreen mode

Pattern-matched error handling. TypeScript enforces exhaustiveness — if you add a new error type, the compiler tells you every unhandled site.

Built-In Retry and Schedule

import { Effect, Schedule } from "effect"

const withRetry = getUser(id).pipe(
  Effect.retry(
    Schedule.exponential("100 millis").pipe(
      Schedule.compose(Schedule.recurs(3))
    )
  )
)
Enter fullscreen mode Exit fullscreen mode

No p-retry. No async-retry. Schedule is composable — jitter, exponential backoff, max attempts, all chainable.

Dependency Injection Without a Framework

import { Effect, Context, Layer } from "effect"

interface Database {
  readonly findUser: (id: string) => Promise<User | null>
}
const Database = Context.GenericTag<Database>("Database")

const getUser = (id: string) =>
  Database.pipe(
    Effect.flatMap(db => Effect.tryPromise(() => db.findUser(id)))
  )

// Production layer
const ProdDatabase = Layer.succeed(Database, {
  findUser: (id) => prodDb.users.findById(id)
})

// Test layer — swap without touching business logic
const TestDatabase = Layer.succeed(Database, {
  findUser: (id) => Promise.resolve(mockUsers[id] ?? null)
})

// Run with a specific layer
Effect.runPromise(
  getUser("123").pipe(Effect.provide(ProdDatabase))
)
Enter fullscreen mode Exit fullscreen mode

No IoC container. No decorators. Pure TypeScript.

Validation With Schema

Effect ships its own schema library that replaces Zod:

import { Schema, ParseResult } from "effect"

const UserInput = Schema.Struct({
  email: Schema.String.pipe(Schema.minLength(5), Schema.pattern(/.+@.+/)),
  age: Schema.Number.pipe(Schema.int(), Schema.between(0, 150)),
  role: Schema.Literal("admin", "user", "viewer")
})

// Decode returns an Effect, not a throw
const decoded = Schema.decodeUnknown(UserInput)(req.body)

// Error messages are structured, not thrown
decoded.pipe(
  Effect.catchAll(e => Effect.succeed({ error: ParseResult.TreeFormatter.formatError(e) }))
)
Enter fullscreen mode Exit fullscreen mode

When to Reach for Effect

Worth it when:

  • You're building long-running services with complex error domains
  • Multiple async operations need composable retry/timeout/concurrency
  • You want compile-time guarantees on error handling exhaustiveness

Probably overkill when:

  • Simple CRUD endpoints with 1–2 error cases
  • Team isn't already comfortable with functional TypeScript
  • Greenfield app where Zod + try/catch is sufficient

The learning curve is real. The payoff is a codebase where error paths are as visible as happy paths.


Want a production-ready TypeScript SaaS foundation with proper error handling baked in? The AI SaaS Starter Kit ships with typed error boundaries, Stripe billing, and AI integration — skip 80 hours of boilerplate setup.

Top comments (0)