Home / Part 1

Part 1 — The why

What is Effect

Effect is a TypeScript library for building complex applications that stay readable and reliable as they grow. This part covers the one idea behind it, and three concrete things that get better because of it.

Follow along with the code

Part 1 uses the real effect library. The runnable files are in the repo — open them, run them, and step through them with a debugger as you read.

The one-line answer

One idea runs through the whole library: use the type system to track errors and context, not just success values. A normal type tells you what a function returns when everything goes right. Effect's type tells you that, plus how it can fail, plus what it needs to run.

A normal type hides two of those three things: how it can fail, and what it needs. Both turn into surprises — an exception you didn't expect, a dependency you forgot to wire up. The third, async, is the opposite: a Promise<string> already shows up in the type. But that visibility is its own problem — it forces every caller to become async too. We come back to it as Problem 3.

The shape

Effect<Success, Error, Requirements> — the value you get if it works, the ways it can fail (as values, not thrown exceptions), and what it needs from the outside.

The rest of Part 1 shows this on a real-shaped program. We take one small feature — a web handler that reads a user profile from a database — and write it twice. It has the shape every backend has:

handler · maps to HTTP service · getProfile repository · findUser database calls down ↓ values & errors flow up ↑
A handler calls a service, the service calls a repository, the repository talks to the database. All three problems show up in this stack.

Problem 1 · Errors are invisible

Each layer can fail. findUser can throw UserNotFound or DbError; getProfile above it just forwards whatever comes up. In plain code none of that shows in the signatures — they only mention the success value:

Plain — the type says nothing about failure
// type says `User` — not "or throws"
const findUser = (id: string): User => {
  const row = db.query(id)              // may throw DbError
  if (!row) throw new UserNotFound(id)  // may throw UserNotFound
  return row
}

// forwards findUser's failures, still invisibly
const getProfile = (id: string): Profile => {
  const user = findUser(id)
  return { name: user.name, email: user.email }
}
Effect — each layer states how it fails
const findUser = (
  id: string
): Effect.Effect<User, UserNotFound | DbError> => ...

const getProfile = (
  id: string
): Effect.Effect<Profile, UserNotFound | DbError | ValidationError> =>
  Effect.gen(function* () {
    const user = yield* findUser(id)  // a User, or the failure propagates
    return { name: user.name, email: user.email }
  })

On the left you only learn the failure modes by reading the bodies. On the right they're in the type, and the body still reads as the happy path: yield* findUser(id) hands you a User and moves on — if it failed, propagation happens on its own.

Because the plain types stay silent, all the error handling collapses into one catch at the top, where the compiler can't check it. Effect handles each failure once, at the edge, and the compiler does check it:

Plain — guess what arrived
try {
  const profile = getProfile(id)
  return { status: 200, body: profile }
} catch (err) {
  // err is `unknown`. we narrow by hand.
  if (err instanceof UserNotFound)
    return { status: 404, body: err.message }
  if (err instanceof ValidationError)
    return { status: 422, body: err.message }
  if (err instanceof DbError)
    return { status: 500, body: "internal" }
  return { status: 500, body: "unknown" }
}
Effect — handled, and checked
const handleGetProfile = (id: string): Effect.Effect<HttpResponse> =>
  getProfile(id).pipe(             // declared error slot: none
    Effect.map((p): HttpResponse =>
      ({ status: 200, body: JSON.stringify(p) })),
    Effect.catchTags({
      UserNotFound:    (e) => Effect.succeed({ status: 404, body: `no user ${e.id}` }),
      ValidationError: (e) => Effect.succeed({ status: 422, body: e.reason }),
      DbError:         ()  => Effect.succeed({ status: 500, body: "internal" }),
    })
  )

The point is the return type: Effect.Effect<HttpResponse> declares an empty error slot (never), which asks the compiler to hold you to it. Delete a handler and the unhandled error stays in the type, no longer matches never, and it stops compiling. The plain catch can't do that: err is unknown, and nothing forces the list to be complete. Add a new error three layers down and it still compiles, silently becoming a 500.

When reading Effect code, you feel comfortable acting as though your code never fails, because you know all failures are tracked and accounted for at the edge, which is an amazing feeling to have when programming!

Problem 2 · Dependencies get threaded everywhere

The repository needs a database client. Only the repository uses it. But in plain code the only way to get it there is to pass it through every function in between.

Plain: db carried by everyone handler(db, id) just forwards it getProfile(db, id) just forwards it findUser(db, id) actually uses it Effect: only the user asks handler(id) no db getProfile(id) no db yield* Database asks for it
On the left, two layers carry db just to pass it down. On the right, only the repository mentions it — and it asks for it instead of receiving it.
"Why not just import the db where it's used?"

Fair question, and for a toy you could. Two reasons it doesn't hold up:

1. It's a hidden input. A function that reaches for an imported db has a dependency its signature doesn't show. That's harder to reason about, and harder to test — to swap the database for a fake you now have to mock the module, not just pass a different argument.

2. There's often no single db to import. Real clients are frequently built per request — carrying the logged-in user's identity so the database can enforce row-level security. A different client every request means you must supply it when you run, not hard-code it at the top of a file.

Both point at the same fix: make the dependency something the program asks for and you supply from the outside, at the point you run it. That's what the service below does.

The database is declared as a service. The repository asks for it; nobody else mentions it. The requirement then rides along in the third slot of the type — the R — through every function that uses it, automatically:

Declare & ask
class Database extends Context.Tag("Database")<
  Database,
  { findUser: (id: string) => Effect.Effect<User, UserNotFound> }
>() {}

const findUser = (id: string) =>
  Effect.gen(function* () {
    const db = yield* Database   // pull it from context
    return yield* db.findUser(id)
  })
Supply once, at the edge
// the need shows up in the type, automatically:
//   Effect.Effect<Profile, UserNotFound, Database>
//                                          ^ "needs a Database"

handleGetProfile(id).pipe(
  Effect.provideService(Database, DatabaseLive)
)
// after provide, R is `never` — nothing left to
// supply — and the program can run. to test, provide
// a different Database and change nothing else.

The dependency lives in the type, not in the call signatures. You satisfy it once, where you run.

Problem 3 · Async coloring

In plain JavaScript, async is contagious. The moment loadName becomes async, greet has to add await and become async too — and so does every caller above it, all the way up. One change at the bottom, a ripple through the whole chain.

Plain — sync version
const loadName = (id: string): string =>
  db.get(id)

const greet = (id: string): string =>
  `hello, ${loadName(id)}`
Plain — make loadName async
const loadName = (id: string): Promise<string> =>   // now async
  db.getAsync(id)

const greet = async (id: string): Promise<string> => // forced async
  `hello, ${await loadName(id)}`                       // forced await

The reason is the type. Promise<string> is a different type from string, so the change surfaces at every call site and has to be handled there.

Plain — the change spreads up caller() → now async greet() → now async loadName() sync → async change the bottom one, the two above must change Effect — the change stays put caller() unchanged greet() unchanged loadName() sync → async same type above, so nothing above moves
Both columns make the same change — loadName goes from sync to async. On the left it forces every caller above to change; on the right, because the type stays Effect<string>, nothing above moves.

Effect's type doesn't change between sync and async — Effect<string> is the same type either way. So greet is written once and never mentions sync or async:

const greet = (id: string): Effect.Effect<string> =>
  Effect.gen(function* () {
    const name = yield* loadName(id)
    return `hello, ${name}`
  })

// switch loadName's body between these two — greet's type is
// unchanged, and so is every caller above it:
const loadName = (id: string) => Effect.sync(() => db.get(id))         // sync
const loadName = (id: string) => Effect.promise(() => db.getAsync(id)) // async

The "sync vs async" choice is no longer in the type, so it doesn't spread. It's settled at runtime, when the program actually runs — which is exactly what Part 2 explains.

What's going on underneath

In every Effect version, nothing ran until the program was handed to a runner at the very bottom. Calling findUser(id), getProfile(id), even the whole handleGetProfile(id) built values. They didn't do anything. The database wasn't touched.

an Effect a description — does nothing yet runtime walks it it runs work happens, you get an Exit
The core idea

An Effect is not a running program. It's a description of one — a plain data structure. It does nothing until a runtime walks it and carries it out.

Everything in Part 1 falls out of that one fact. Errors can be in the type because they're values in the description, not exceptions that escape. Dependencies can be in the type because "I need a Database" is just a node in the description, waiting to be filled in. Sync and async can share a type because the description doesn't run yet — the runtime decides how at the moment it does.

So the natural next question: what is that description, exactly, and what does the runtime that walks it look like? That's Part 2 — we build both from scratch.