Gerson

Gerson

Passionate developer specializing in web development, cloud architecture, and system design.

TypeScriptReactNext.jsPythonFastAPISQLNode.jsAWS

Effect: Building Bulletproof TypeScript Applications

An in-depth look at Effect — the TypeScript library that makes errors, dependencies, and async behavior explicit in your type system. Covers the Effect type, typed error handling, dependency injection with Services and Layers, Schema validation, concurrency, and real-world patterns.

Gersonhttps://effect.website/
Abstract network connections representing typed effect systems

TypeScript gives you types, but it doesn't give you much help with the things that go wrong at runtime: network failures, missing environment variables, database timeouts, malformed API responses. You catch exceptions with try/catch, but the catch block gives you unknown — so you're back to guessing. Dependencies get passed around through constructor injection or global imports, invisible to the type system. Async operations are Promises that can reject with anything.

Effect is a TypeScript library that makes all of these concerns explicit and type-safe. Its core idea is a single type — Effect<Success, Error, Requirements> — that encodes what a computation produces, how it can fail, and what it needs to run. If that sounds academic, don't worry: the practical result is code that's dramatically easier to reason about, test, and refactor.

With around 13,000 GitHub stars and millions of NPM downloads, Effect has moved well past the experimental stage. Let's dig into what it offers and how to use it.

The Core Type

Everything in Effect revolves around the Effect<A, E, R> type, where:

  • A (Success) — the value the computation produces when everything goes right
  • E (Error) — the specific, typed error(s) it can fail with
  • R (Requirements) — the services or dependencies it needs before it can execute

An Effect is a description of work to be done, not the execution of it. Creating an Effect doesn't run anything — it's like writing a recipe. You explicitly run it later with Effect.runPromise or Effect.runSync.

This laziness is intentional. It means you can compose, transform, and combine Effects without triggering side effects, and the runtime handles execution, concurrency, and resource cleanup.

Creating and Running Effects

Effect provides different constructors depending on whether your code is synchronous or async, and whether it might throw:

Basic constructors

import { Effect } from "effect"

// A known value — like Promise.resolve()
const greeting = Effect.succeed("Hello, Effect!")
// Type: Effect<string, never, never>

// A known failure
const boom = Effect.fail(new Error("something broke"))
// Type: Effect<never, Error, never>

// Synchronous code that won't throw
const now = Effect.sync(() => new Date().toISOString())
// Type: Effect<string, never, never>

// Synchronous code that might throw
const parsed = Effect.try(() => JSON.parse(rawInput))
// Type: Effect<unknown, UnknownException, never>

// Async code (Promise that won't reject)
const delayed = Effect.promise(() =>
  new Promise<string>(resolve => setTimeout(() => resolve("done"), 1000))
)
// Type: Effect<string, never, never>

// Async code that might reject, with a typed error mapping
const fetched = Effect.tryPromise({
  try: () => fetch("https://api.example.com/data"),
  catch: (err) => new NetworkError({ reason: String(err) }),
})
// Type: Effect<Response, NetworkError, never>

To actually execute an Effect:

Running effects

// Returns a Promise — most common in application code
const result = await Effect.runPromise(greeting) // "Hello, Effect!"

// Synchronous (throws if the Effect is async or fails)
const value = Effect.runSync(now)

// Returns Promise<Exit<A, E>> — gives you success or failure without throwing
const exit = await Effect.runPromiseExit(fetched)

Composing Effects with Generators

You can chain Effects with pipe and flatMap, but the most readable approach is Effect.gen, which uses JavaScript generators to give you an async/await-like syntax:

Generator-style composition

const program = Effect.gen(function* () {
  const response = yield* Effect.tryPromise({
    try: () => fetch("https://api.example.com/users/1"),
    catch: () => new NetworkError({ reason: "fetch failed" }),
  })

  const json = yield* Effect.tryPromise({
    try: () => response.json(),
    catch: () => new ParseError({ message: "invalid JSON" }),
  })

  return json as User
})
// Type: Effect<User, NetworkError | ParseError, never>

Each yield* unwraps a successful Effect, much like await unwraps a Promise. But unlike await, the errors accumulate in the type signature — the compiler knows this function can fail with either NetworkError or ParseError, and it won't let you ignore that.

Typed Error Handling

This is where Effect really differentiates itself from standard TypeScript. Instead of catch (e: unknown), you get errors that are fully typed and can be handled individually by their tag:

Defining and handling typed errors

import { Data, Effect } from "effect"

// Define errors using Data.TaggedError — gives you a _tag, stack trace,
// structural equality, and destructuring
class NetworkError extends Data.TaggedError("NetworkError")<{
  url: string
  statusCode: number
}> {}

class ValidationError extends Data.TaggedError("ValidationError")<{
  field: string
  message: string
}> {}

// A function whose error types are visible in the signature
const fetchUser = (id: string) =>
  Effect.gen(function* () {
    const res = yield* Effect.tryPromise({
      try: () => fetch(`/api/users/${id}`),
      catch: () => new NetworkError({ url: `/api/users/${id}`, statusCode: 0 }),
    })

    if (!res.ok) {
      return yield* Effect.fail(
        new NetworkError({ url: `/api/users/${id}`, statusCode: res.status })
      )
    }

    const data = yield* Effect.try({
      try: () => JSON.parse("...") as { name: string; email: string },
      catch: () => new ValidationError({ field: "body", message: "bad JSON" }),
    })

    return data
  })
// Type: Effect<{ name: string; email: string }, NetworkError | ValidationError, never>

// Handle errors selectively with catchTag
const withFallback = fetchUser("123").pipe(
  Effect.catchTag("NetworkError", (err) =>
    Effect.succeed({ name: "offline", email: `error-${err.statusCode}` })
  ),
  // ValidationError is still unhandled — the compiler enforces this!
  Effect.catchTag("ValidationError", (err) =>
    Effect.succeed({ name: "unknown", email: err.message })
  ),
)
// Type: Effect<{ name: string; email: string }, never, never>
// All errors handled — the E channel is now 'never'

The key insight: if you only handle NetworkError, the remaining ValidationError stays in the type signature. The compiler won't let you run the Effect until every error is accounted for. This is a fundamentally different model from try/catch, where nothing prevents you from silently swallowing exceptions.

Dependency Injection with Services and Layers

The third type parameter (R) tracks what services your code depends on. Instead of importing a database client directly or passing it through constructor arguments, you declare the dependency in the type and let the runtime provide it:

Defining services

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

// Step 1: Define what the service looks like
class UserRepo extends Context.Tag("@app/UserRepo")<
  UserRepo,
  {
    readonly findById: (id: string) => Effect.Effect<User | null>
    readonly save: (user: User) => Effect.Effect<void>
  }
>() {}

class Logger extends Context.Tag("@app/Logger")<
  Logger,
  {
    readonly info: (msg: string) => Effect.Effect<void>
    readonly error: (msg: string) => Effect.Effect<void>
  }
>() {}

// Step 2: Write business logic that uses the services
const getUser = (id: string) =>
  Effect.gen(function* () {
    const repo = yield* UserRepo
    const log = yield* Logger

    yield* log.info(`Looking up user ${id}`)
    const user = yield* repo.findById(id)

    if (!user) {
      yield* log.error(`User ${id} not found`)
      return yield* Effect.fail(new UserNotFoundError({ id }))
    }

    return user
  })
// Type: Effect<User, UserNotFoundError, UserRepo | Logger>
// The R parameter tells you exactly what this code needs

Providing implementations with Layers

// Step 3: Create concrete implementations
const LoggerLive = Layer.succeed(Logger, {
  info: (msg) => Effect.sync(() => console.log(`[INFO] ${msg}`)),
  error: (msg) => Effect.sync(() => console.error(`[ERROR] ${msg}`)),
})

const UserRepoLive = Layer.succeed(UserRepo, {
  findById: (id) => Effect.sync(() => ({ id, name: "Alice", email: "a@b.com" })),
  save: (_user) => Effect.sync(() => {}),
})

// Step 4: Compose layers and provide them
const AppLive = Layer.merge(LoggerLive, UserRepoLive)

const runnable = getUser("user-1").pipe(Effect.provide(AppLive))
// Type: Effect<User, UserNotFoundError, never>
// R is now 'never' — all dependencies satisfied

Effect.runPromise(runnable)

For testing, you swap in different layers with mock implementations. The business logic doesn't change — only the wiring at the edge of your application. By convention, production layers are suffixed with Live and test layers with Test.

Schema Validation

Effect includes its own schema library (@effect/schema in v3, or effect/Schema in v4) that can replace tools like Zod. The headline feature: schemas define both decoding (parsing raw input into typed values) and encoding (serializing typed values back to wire format), something Zod doesn't do natively.

Schema definitions

import { Schema } from "effect"

const UserSchema = Schema.Struct({
  id: Schema.Number,
  name: Schema.String.pipe(Schema.minLength(1)),
  email: Schema.String,
  role: Schema.Literal("admin", "user", "guest"),
  createdAt: Schema.DateFromString, // decodes string → Date, encodes Date → string
})

// Infer the TypeScript type from the schema
type User = typeof UserSchema.Type
// { id: number; name: string; email: string; role: "admin" | "user" | "guest"; createdAt: Date }

// The "over the wire" shape
type UserEncoded = typeof UserSchema.Encoded
// { id: number; name: string; email: string; role: "admin" | "user" | "guest"; createdAt: string }

// Parse unknown input (throws on failure)
const user = Schema.decodeUnknownSync(UserSchema)({
  id: 1, name: "Alice", email: "alice@example.com",
  role: "admin", createdAt: "2026-01-15T00:00:00Z",
})
// user.createdAt is a Date object

// Serialize back to JSON-safe format
const json = Schema.encodeSync(UserSchema)(user)
// json.createdAt is a string again

// Or get an Effect instead of throwing
const parseUser = Schema.decodeUnknown(UserSchema)
// (input: unknown) => Effect<User, ParseError, never>

This bidirectional decode/encode capability is useful whenever you need to both parse incoming data and serialize outgoing data — API boundaries, database layers, message queues, and WebSocket payloads.

Concurrency

Effect has built-in concurrency primitives based on lightweight fibers (similar to goroutines or Erlang processes). You don't need external libraries for parallel execution, racing, timeouts, or controlled concurrency:

Concurrency patterns

import { Effect, Fiber } from "effect"

// Run multiple effects in parallel
const [users, posts, comments] = await Effect.runPromise(
  Effect.all(
    [fetchUsers, fetchPosts, fetchComments],
    { concurrency: "unbounded" }
  )
)

// Process a list with controlled parallelism (e.g., max 3 at a time)
const results = Effect.forEach(
  userIds,
  (id) => fetchUser(id),
  { concurrency: 3 }
)

// Race: first to complete wins, the loser is automatically interrupted
const fastest = Effect.race(
  Effect.delay(Effect.succeed("primary"), "2 seconds"),
  Effect.delay(Effect.succeed("fallback"), "5 seconds"),
)
// Result: "primary"

// Timeout with automatic interruption
const withTimeout = longRunningTask.pipe(
  Effect.timeout("10 seconds")
)

// Fork a fiber (background task) and join it later
const program = Effect.gen(function* () {
  const fiber = yield* Effect.fork(backgroundJob)
  // ... do other work ...
  const result = yield* Fiber.join(fiber)
  return result
})

A key advantage here: when an Effect is interrupted (via timeout, race loser, or fiber interruption), any resources it acquired are cleaned up automatically. There's no need for manual finally blocks.

Retries and Scheduling

Transient failures are a fact of life with network services. Effect provides composable schedules for retries, polling, and recurring work:

Retry and schedule patterns

import { Effect, Schedule } from "effect"

// Retry a failing effect up to 3 times
const resilient = Effect.retry(fetchData, Schedule.recurs(3))

// Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1600ms
const withBackoff = Effect.retry(
  fetchData,
  Schedule.exponential("100 millis").pipe(
    Schedule.compose(Schedule.recurs(5))
  )
)

// Poll an endpoint every 10 seconds
const poller = Effect.repeat(checkStatus, Schedule.spaced("10 seconds"))

When Effect Might Not Be the Right Fit

Effect is a powerful tool, but it's worth being honest about the trade-offs:

  • Learning curve. The mental model is a significant departure from typical TypeScript. Generators, fibers, layers, and tagged errors are all new concepts your team needs to learn. If your team doesn't have functional programming experience, expect a ramp-up period.
  • Ecosystem maturity. While growing, the Effect ecosystem is still young. You'll likely write wrapper code to integrate non-Effect libraries, and third-party articles can be outdated since the API has evolved rapidly.
  • Overhead for simple apps. A CRUD API with straightforward error handling doesn't necessarily benefit from the ceremony of Effects, Layers, and Services. The library shines in complex domains — orchestration, pipelines, systems with many failure modes and dependencies.
  • Tends toward all-or-nothing. While Effect is technically adoptable incrementally, in practice codebases tend to go fully Effect or not at all, since crossing the boundary between Effect and non-Effect code introduces friction.

That said, if you're building something where reliability, testability, and typed error handling genuinely matter — backend services, data pipelines, or anything that orchestrates unreliable external systems — Effect is worth the investment.

How It Compares

vs. plain try/catch: The most obvious comparison. catch gives you unknown; Effect gives you fully typed errors in the function signature. Effect also handles dependencies and concurrency, which try/catch doesn't address at all.

vs. neverthrow: neverthrow gives you a Result<T, E> type for typed errors, which is great if that's all you need. Effect goes much further — dependency injection, concurrency, streams, schemas, resource management — but the trade-off is a bigger learning curve and bundle size.

vs. Zod (for schema validation): Zod is decode-only; Effect Schema does both decode and encode. Effect Schema also supports branded types, template literals, and generates test data for property-based testing. But Zod has a much larger ecosystem of integrations (React Hook Form, tRPC, etc.).

Resources