← All posts

Effect for TypeScript: architecture, typed errors, dependencies, and concurrency

How Effect puts errors, dependencies, and concurrency into the type system: Layer, Schema, comparison with Promise, Zod, and fp-ts — and when the library is overkill.

Effect for TypeScript: architecture, typed errors, dependencies, and concurrency
Contents

You open getUser and see Promise<User>. The type suggests a simple deal: pass an id, get a user. Inside, though, the function already hides a network call, JSON parsing, a database client, and a timeout copied from another file. TypeScript does not surface any of that. The signature only names the happy path.

A useful picture is packing for a hike. The label on the pack says only "reach the summit". You still need to know what can go wrong (a washed-out trail, bad weather) and what you must bring (map, rope, first-aid kit). Ordinary TypeScript often leaves only the summit on the label. Effect makes you write the rest into the type: the success value (A), the expected failures (E), and the required dependencies (R) — and the compiler can read the full list before the program runs.

This article targets the Effect v4 ecosystem (the effect package and its sibling modules, which share one version number). Check every example against the Effect documentation for your exact major version: between v3 and v4 the service API changed (Context.Service instead of several older Tag-style APIs) and the packages were reorganized.

Key takeaways

TypeScript describes the shape of data, not the full contract of execution. The signature Promise<User> says nothing about network failures, timeouts, or which repositories the code needs. Effect.Effect<A, E, R> names the success value, the expected failures, and the environment explicitly.

The central shift is the program as a value. You first build a description of the computation, compose it with other descriptions, swap in test dependencies, and only then run it. It is the route sheet you check before leaving base camp.

Errors split into expected failures and defects. Expected ones (UserNotFound, a network drop) live in the E channel and are handled with catchTag. Defects are broken invariants; you do not paper over them with a "successful" fallback unless a deliberate policy says so.

Services and Layers replace hidden singletons. A service contract is separate from its implementation; a test provides a different Layer instead of a global mock with fragile initialization order.

Schema closes the type hole at the system boundary. The compiler cannot protect you from somebody else's JSON. A schema decodes unknown into your internal model and reports failures through the same error machinery as the rest of the program.

Effect pays off where complexity has already sprawled. A small CRUD app with one HTTP call, a couple of form fields, and a "status 200" test is often simpler in plain TypeScript: the cost of learning the library outweighs the gain from typed error channels and layers. If a two-person team can still read try/catch without getting lost in retries, do not drag Effect in for fashion.

It is a different story once a service has grown into a payment gateway, customer records, warehouse, mailer, cache, queue, and three more internal APIs. Each one has its own timeout, failure shape, and test-double ritual. Retry policies live in three helpers, cancellation works in only half the call sites, and a new hire spends a week discovering which exceptions are even possible. Here Effect pays for itself by putting errors, dependencies, and concurrency into one type-level language — instead of a zoo of local fixes.

A practical rule of thumb: if a noticeable share of your time already goes to coordinating infrastructure (retries, mocks, JSON boundaries) rather than domain logic, you have probably crossed the break-even point. If that pain is absent, the library becomes an end in itself; the adoption and 2026 sections below spell that out.

When plain TypeScript stops being enough

TypeScript is excellent at catching a misspelled field or a wrong argument. It cannot catch what happens at runtime: an exception deep in the call stack, a third-party API going down, a race during request cancellation, a database connection whose finally block someone forgot.

A typical server function looks familiar. It takes an id, calls an HTTP client, parses JSON, writes a log line, and on failure either throws or returns null. A month later a colleague cannot tell which exceptions are possible, whether a cache is involved, or who owns the timeout. A comment that documents this goes stale faster than the code.

As the system grows, several parallel mini-architectures appear. Here is a try/catch, there is a homemade Result object, over there a retry library, and in another corner a hand-managed AbortController. Each mechanism fixes a local pain, but together they do not form a shared language for the team. It is like an expedition where every sub-team packs by its own list: nobody can say whether the whole group has rope.

The question is not whether TypeScript is bad. The question is whether a program's behavior can be made more explicit and checkable at the type level without abandoning Node and React. That is the gap Effect fills.

What Effect is and which problem it solves

Effect is a library and an ecosystem for TypeScript. The core provides a computation type, error handling, dependency injection, resource management, and concurrency. Around it sit Schema, Stream, Schedule, HTTP, SQL, CLI, observability, and modules for AI applications. It does not replace the language, React, or Node. It gives you one set of abstractions for organizing program logic and infrastructure concerns.

There is a link to functional programming, but selling Effect as a mandatory FP cult does it a disservice. In practice a team writes generators (Effect.gen) in a style close to async/await, and composition plus explicit error types deliver an engineering gain without monad jargon in every code review.

Effect covers, as one piece, what people usually stitch together from parts: typed errors, DI without hidden singletons, safe resource release, timeouts and retries, structured concurrency. A standalone retry utility cannot do that, because it shares no vocabulary with your DI container or your data schemas.

The program as a value: the Effect<A, E, R> type

In ordinary code, calling a function starts the work immediately. In Effect you describe the computation as a value that has not run yet. You can pass it around, wrap it in a timeout, combine it with another computation, and provide a different environment — all before runPromise or NodeRuntime.runMain.

The three type parameters (in the docs and in compiler output) read like this:

Parameter Meaning Expedition analogy
A Success result Destination
E Expected errors you can recover from Known risks on the route
R Required services / environment Gear and crew
import { Effect, Schema } from "effect"

class NetworkError extends Schema.TaggedError<NetworkError>()("NetworkError", {
  message: Schema.String
}) {}

// Effect<number, NetworkError, never>
const loadScore = Effect.gen(function* () {
  const ok = Math.random() > 0.2
  if (!ok) {
    return yield* new NetworkError({ message: "upstream timeout" })
  }
  return 42
})

Here is how this compares with the familiar forms:

Form What the type shows What it hides
() => User A successful object Side effects, errors
() => Promise<User> Asynchronous success The full set of errors and dependencies
Effect.Effect<User, E, R> Success, errors, requirements Guarantees about the outside world (the network can still lie)

Separating description from execution helps tests: you assemble the same program graph as in production but provide a test Layer. Effect does not promise that an external API is honest. It promises that your contract of errors and dependencies will not hide inside implicit exceptions.

Typed errors instead of a vague try/catch

try/catch catches everything: "user not found", a typo in the code, a dropped socket. Meanwhile the function type stays at "always returns User" until somebody throws. Expected business failures and unexpected defects get mixed together.

In Effect, expected errors are modeled as typed values — conveniently through Schema.TaggedError — and handled precisely:

import { Effect, Schema } from "effect"

class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {
  id: Schema.String
}) {}

class ValidationFailed extends Schema.TaggedError<ValidationFailed>()(
  "ValidationFailed",
  { message: Schema.String }
) {}

declare const fetchUser: (
  id: string
) => Effect.Effect<{ id: string; email: string }, UserNotFound | ValidationFailed>

const program = fetchUser("42").pipe(
  Effect.catchTag("UserNotFound", (e) =>
    Effect.succeed({ id: e.id, email: "[email protected]" })
  ),
  Effect.catchTag("ValidationFailed", (e) =>
    Effect.fail(e) // pass it further up the architecture
  )
)

Errors travel through the layers of the architecture as data. A controller can map UserNotFound to HTTP 404 and ValidationFailed to 400 without guessing from the text of Error.message. Retries and fallbacks (Effect.retry, a policy built with Schedule) attach to the failures worth repeating — usually the network, not "record not found".

A practical scenario: fetching a user from an API. The record may be missing, the network may fail, and the response body may not validate. With Effect the calling code sees all three in the type; with Promise<User> it sees none.

Async, concurrency, and cancellation

Promise and Effect both describe asynchronous work, but differently. A Promise starts when it is created; an Effect starts when the runtime runs it. A Promise is poor at expressing compound policies (timeout plus retry plus cancelling child tasks) as a single type. Effect gives you combinators and a model of structured concurrency: the lifetime of child operations is tied to the parent.

Typical tasks:

  • limit how long you wait (Effect.timeout);
  • retry on a transient failure (Effect.retry with Schedule);
  • launch several requests and wait for all of them, or for the first;
  • abort the work when the client cancels the HTTP request.

On cancellation it matters not only to "stop waiting" but to release resources. If you opened a connection or a file, its finalizer must run. This is where concurrency meets resource management: cancellation is a normal path, not a hope that the garbage collector will close the socket.

The guarantees stay honest. Effect orders your code. If an external SDK ignores AbortSignal, the library cannot work a miracle — but you can at least see exactly where your responsibility ends.

Services and Layers: dependencies as part of the type

A global database client or a module-level singleton complicates tests: you patch imports, clean state between cases, and remember the initialization order. Effect proposes another move: a service is a contract, and its implementation is a separate Layer.

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

class DbError extends Schema.TaggedError<DbError>()("DbError", {
  message: Schema.String
}) {}

export class UserRepo extends Context.Service<
  UserRepo,
  {
    findById: (
      id: string
    ) => Effect.Effect<{ id: string; email: string }, DbError>
  }
>()("myapp/UserRepo") {
  static readonly layer = Layer.effect(
    UserRepo,
    Effect.gen(function* () {
      const findById = Effect.fn("UserRepo.findById")(function* (id: string) {
        // live implementation: SQL, HTTP, cache…
        return { id, email: `${id}@example.com` }
      })
      return UserRepo.of({ findById })
    })
  )

  static readonly layerTest = Layer.succeed(
    UserRepo,
    UserRepo.of({
      findById: (id) => Effect.succeed({ id, email: "[email protected]" })
    })
  )
}

const getEmail = Effect.gen(function* () {
  const repo = yield* UserRepo
  const user = yield* repo.findById("u-1")
  return user.email
})

// production
const live = getEmail.pipe(Effect.provide(UserRepo.layer))
// test
const test = getEmail.pipe(Effect.provide(UserRepo.layerTest))

Layers compose. A repository depends on config and an HTTP client, the user service depends on the repository and an external API. The R type shows what is still missing until you provide the full graph. That is dependency injection without the hidden magic of a string-keyed container.

Schema: validating data at system boundaries

TypeScript types are erased at compile time. Partner JSON, an HTTP request body, environment variables, an LLM response — all of it is effectively unknown, even if you wrote as User. You need runtime validation that agrees with your internal models.

Effect Schema defines a structure, decodes input, and encodes output. A parse failure joins the same error channel as the rest of the program, instead of a zoo where Zod exceptions live in one corner and try/catch in another. (Zod lives perfectly well in projects without Effect; see the comparison below.)

import { Effect, Schema } from "effect"

const UserDto = Schema.Struct({
  id: Schema.String,
  email: Schema.String
})

type UserDto = typeof UserDto.Type

const parseUser = (input: unknown) =>
  Schema.decodeUnknown(UserDto)(input)

const program = Effect.gen(function* () {
  const raw: unknown = JSON.parse('{"id":"1","email":"[email protected]"}')
  return yield* parseUser(raw)
})

A real-life case: a server "slightly changed" a field or sent a number instead of a string. The schema fails loudly at the boundary, and no half-parsed object leaks into the domain. The same approach works for configuration — in the spirit of the article on validating environment variables in TypeScript, only built into the common Effect model.

Resources and a short look at the ecosystem

Files, connection pools, sockets, and subscriptions cannot be "forgotten" on error or cancellation. Effect uses Scope and finalizers (Effect.acquireRelease and related APIs): acquisition and release are tied together so that leaving the scope — including by interruption — triggers cleanup. That is more predictable than manual try/finally, which is easy to bypass with an early return in another layer or lose during a refactor.

A quick pass over neighboring modules — not an API catalog, but a map of where to look next:

  • Stream — data streams where a slow consumer paces a fast producer (event feeds, paginated fetches).
  • Schedule — schedules for retries and periodic tasks.
  • Logging and tracing — observing the same graph of effects.
  • HTTP / SQL / CLI / AI — application packages of the v4 ecosystem sharing one version.

Pull in a module when the task already exists, not "because the ecosystem looks nice". An expedition does not carry a canoe across the desert.

End-to-end example: a user service

First, recognizable TypeScript on Promise. Then the same scenario on Effect. The task: fetch a user from an external API, validate the JSON, stay within a timeout, retry on a transient failure, and swap out the network in a test.

Plain TypeScript

type User = { id: string; email: string }

class UserNotFoundError extends Error {
  constructor(readonly id: string) {
    super(`User ${id} not found`)
  }
}

async function fetchUserJson(id: string): Promise<unknown> {
  const res = await fetch(`https://api.example.com/users/${id}`)
  if (res.status === 404) throw new UserNotFoundError(id)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
}

function parseUser(data: unknown): User {
  if (
    typeof data !== "object" ||
    data === null ||
    typeof (data as User).id !== "string" ||
    typeof (data as User).email !== "string"
  ) {
    throw new Error("invalid user payload")
  }
  return data as User
}

async function getUser(id: string): Promise<User> {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), 2000)
  try {
    // simplified: retries, AbortSignal, and parsing are usually smeared across helpers
    const raw = await fetchUserJson(id)
    return parseUser(raw)
  } finally {
    clearTimeout(timer)
  }
}

Infrastructure (timeout, retry, validation, a test substitute for fetch) quickly sprawls. The error contract can only be read from the text of exceptions.

The same scenario in Effect

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

class UserNotFound extends Schema.TaggedError<UserNotFound>()("UserNotFound", {
  id: Schema.String
}) {}

class NetworkError extends Schema.TaggedError<NetworkError>()("NetworkError", {
  message: Schema.String
}) {}

const User = Schema.Struct({
  id: Schema.String,
  email: Schema.String
})
type User = typeof User.Type

export class UserApi extends Context.Service<
  UserApi,
  {
    getById: (
      id: string
    ) => Effect.Effect<User, UserNotFound | NetworkError>
  }
>()("myapp/UserApi") {
  static readonly layerTest = Layer.succeed(
    UserApi,
    UserApi.of({
      getById: (id) => Effect.succeed({ id, email: "[email protected]" })
    })
  )
}

const loadUser = Effect.fn("loadUser")(function* (id: string) {
  const api = yield* UserApi
  return yield* api.getById(id).pipe(
    Effect.timeout("2 seconds"),
    Effect.retry({ times: 2, schedule: Schedule.exponential("100 millis") }),
    Effect.catchTag("TimeoutError", () =>
      Effect.fail(new NetworkError({ message: "timeout" }))
    )
  )
})

const testProgram = loadUser("u-1").pipe(Effect.provide(UserApi.layerTest))

Comparison along the axes we set out earlier:

Axis Promise style Effect
Infrastructure code Often spread across helpers Policies sit next to the computation
Contracts Barely visible in types E and R in the signature
Testability Module mocks / global fetch Swap a Layer
Errors Exceptions and strings Tagged types
Entry barrier Low Higher: abstractions and a new style

For a teaching-grade CRUD app, Effect's gain may not repay the learning cost. For a service with several dependencies and strict tests, it is the opposite.

How to adopt Effect and what it competes with

The biggest payoff is in backends and services with many external dependencies, where tests, retries, and scattered error handling already hurt. Adopt gradually: a new module or an API boundary on Effect, the rest on Promise. The bridge is ManagedRuntime plus explicit adapters at the edge (an HTTP handler calls runtime.runPromise(program)).

Not every small program needs Effect. A hundred-line script, a storefront with plain CRUD, a weekend prototype — ordinary TypeScript is the honest choice. The cost of teaching a team is real: you need shared rules (how to declare errors, how to cut layers, where Effect ends). Without agreements the library turns into a zoo of styles.

Approach Main purpose
Plain TypeScript + Promise Simple, familiar async code
fp-ts Functional abstractions and composition
Zod Data validation and type inference from schemas
TanStack Query Server state on the client
Effect Errors, dependencies, resources, and concurrency in one model

These tools do not always compete. TanStack Query in offline scenarios solves a UI caching and sync problem, a different one from Effect on the server. Zod can stay on the frontend while the backend moves to Schema. fp-ts is closer in philosophy, but Effect aims at operational completeness for applications: resources, concurrency, layers.

Nearby on the topic of TypeScript contracts: generic constraints, tRPC and shared client/server types, and architecture that lasts a decade.

Is Effect worth learning in 2026

Before starting, it helps to have solid TypeScript (generics, unions, narrowing), some experience with asynchronous code, and a sense of why you want DI and boundary validation. The abstractions feel unfamiliar at first: "program as a value" and the R channel take a few working days, not one tutorial.

Trying Effect in an existing project makes sense if you already feel the pain: brittle tests caused by singletons, different retry styles, leaks on cancellation, holes at the JSON boundary. If there is no pain, the library becomes an end in itself.

What is easier with traditional tools: thin wrappers, one-off scripts, client-side UI state (where Query or a store usually matters more). Judge the maturity of the ecosystem by the v4 release, the shared package version, the documentation, and activity in the Effect-TS/effect repository. Check compatibility with your stack on a small pilot: one service, one test layer, one adapter to Express, Fastify, or Hono — and a retrospective two weeks later.

FAQ

How is Effect different from yet another Promise utility?

A utility usually covers one piece (retries or a Result type). Effect ties errors, dependencies, resources, and concurrency together in one type model, so policies do not live in different styles.

Do I need to rewrite the whole project?

No. Start at a boundary: a new module, an HTTP adapter, a test Layer. The rest of the code can stay on Promise until the pilot proves its value.

Effect instead of Zod?

Not necessarily. Zod is great for many codebases. Inside Effect code, Schema gives you the same error ecosystem and integration with services. On the frontend, Zod is often still a sensible choice.

Is it the same as fp-ts?

Both relate to a functional style, but Effect is more tuned for application concerns: layers, resources, concurrency, a single runtime. fp-ts is closer to a library of abstractions; Effect is an operational platform for the application.

Does Effect suit a React app?

For server logic and heavy clients with a complex domain — yes. For local UI state, tools from the React ecosystem are usually enough. Mix deliberately: Effect on the domain, React on rendering.

What is a defect, and how does it differ from an error in the E channel?

An expected error is part of the domain (entity missing, validation rejected). A defect is a broken invariant or a bug. They get different policies: the former are recovered from or mapped to HTTP codes, the latter lead to a crash and an alert.

How stable is the API in v4?

v4 is a deliberate major release that unifies package versions and updates services. Pin the version in your project and do not mix v3 and v4 examples in one team document.

Conclusion

Effect is useful not as a collection of functions with short names but as a way to describe errors, dependencies, resources, and concurrency explicitly. The value is most visible where the application's complexity no longer lets you keep these mechanisms scattered across separate decisions.

This week: pick one service with an external API, describe its errors with tags, move the client into a Context.Service, write a layerTest, and run one scenario without mocking the global fetch. If your team finds the contract easier to read, the expedition is packed correctly. If not, stay on Promise and write down which pain was still missing to justify switching tools.

Comments

Loading comments…