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.
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.
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:
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:
// 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 }
}
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:
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" }
}
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.
db just to pass it down. On the right, only the repository mentions it — and it asks for it instead of receiving it.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:
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)
})
// 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.
const loadName = (id: string): string =>
db.get(id)
const greet = (id: string): string =>
`hello, ${loadName(id)}`
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.
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 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.