Part 1: Stop Throwing Errors
I want to learn Effect the annoying way: by using it, getting confused, and writing down the parts that finally click.
This is the first note.
Effect is a TypeScript library for writing programs where the scary parts are visible. A function can fail. It can need a database. It can do async work. Normal TypeScript hides a lot of that behind Promise, throw, and comments.
Effect puts those facts in the type.
Effect.Effect<Success, Error, Requirements>;Read that as:
- what you get if it works
- what can go wrong
- what the code needs from the outside world
That is the whole first door.
Start with a normal function
Here is the usual TypeScript version:
const parseUserId = (input: string): number => { const id = Number(input);
if (!Number.isInteger(id)) { throw new Error("User id must be an integer"); }
return id;};The type says this returns a number.
It does not say it can throw.
So the caller sees this:
const id = parseUserId("abc");And the crash is hiding off-screen.
The Effect shape
With Effect, failure becomes part of the return value.
import { Effect } from "effect";
class InvalidUserId { readonly _tag = "InvalidUserId"; constructor(readonly input: string) {}}
const parseUserId = (input: string) => Effect.sync(() => Number(input)).pipe( Effect.flatMap((id) => Number.isInteger(id) ? Effect.succeed(id) : Effect.fail(new InvalidUserId(input)), ), );The important part is not the syntax yet. The important part is the type.
Effect.Effect<number, InvalidUserId, never>;That says:
- success:
number - failure:
InvalidUserId - requirements:
never
never means this effect does not need anything from the outside world. No database. No config. No logger. Nothing.
Effect values do not run by themselves
This part matters.
An Effect is a description of work. Creating one does not run it.
const program = parseUserId("123");That line builds a value. It has not parsed anything yet.
To run it at the edge of your app, you use a runner:
const result = await Effect.runPromise(program);This is different from calling an async function. With a Promise, the work usually starts right away. With Effect, you can build the program first, combine it with other work, handle errors, provide dependencies, and run it once at the edge.
Why bother?
For this tiny example, you probably would not.
A plain function with a union return is enough:
type ParseResult = | { ok: true; value: number } | { ok: false; error: "InvalidUserId" };Effect starts earning its keep when the program has more than one kind of failure and more than one outside dependency.
For example:
- parse the user id
- fetch the user
- call a payment API
- retry only the network error
- log the failure with request context
- return a safe error to the client
That is where try/catch starts to smear across the code. Effect gives you one pipe where success, failure, retry, timeout, and dependencies stay visible.
My rule for now: do not reach for Effect to parse one number. Reach for it when the boring error path is already bigger than the happy path.
The part I am taking with me
The first mental model is simple:
Effect.Effect<Success, Error, Requirements>;If I can read those three slots, I can start reading Effect code.
Next I want to learn the pipe style: succeed, fail, flatMap, and why Effect code often reads inside-out until it clicks