Either: Errors as Values, Not Explosions (Part 5 of the Functional Programming Series)
Part 5 of the Functional Programming in Practice series. Part 4 killed null with Option. But Option tells you a value is missing without telling you why. This part fixes that, and turns errors into honest values.
Hey there, Coding Chefs! 👨💻
Let me tell you about a 2am page I will never forget. I was building WordShot, a real-time word game, and a user kept getting "Internal Server Error" when they tried to start a round. The backend was throwing a LetterSelectionError because the player had picked a combination of categories with no valid starting letters. That is a normal validation problem, the kind you handle with a friendly message. But it was thrown as an exception, caught by a generic catch-all, and turned into a 500. The frontend showed a scary server error, the logs buried the real cause, and I lost an hour digging for what was, in truth, a perfectly ordinary user mistake.
That is the problem with exceptions. They make your function signatures lie. A function says it returns a User, but secretly it might throw a UserNotFoundError, or a ValidationError, or a DatabaseError, and you have no way to know from the type. The error path is invisible until it detonates.
Option, from the last part, was a step toward honesty, but it only says "no value here." It cannot tell you why. A failed validation should say what failed. In this part we meet Either, the structure that turns errors into ordinary values you can see right in the type signature. By the end you will know how to model success-or-failure honestly, how it collapses tangled error handling into a clean linear flow, and you will recognize a pattern many of you are already half-using. Let's get cracking.
Two Tracks: Right and Left
Recall the partial divide2 from the last part. We made it total with Option, returning none for bad input. But none is silent. If divide2 should reject zero with "cannot divide by zero" and reject odd numbers with "number is not even," Option cannot carry those messages. We get absence with no explanation.
What we want is a structure that holds either a good value or an error value, and remembers which. That is Either, a type constructor that takes two types: one for when things go wrong, one for when things go right.
The two cases are named Left and Right. Right holds a success value (think "the right answer"). Left holds an error. The convention is easy to remember: when things go well you go Right, when they go wrong you go Left.
type Left<E> = { tag: "left"; value: E }; // the error case
type Right<A> = { tag: "right"; value: A }; // the success case
type Either<E, A> = Left<E> | Right<A>;
A value of type Either<string, number> is valid if it is a Left wrapping a string, or a Right wrapping a number. Like before, we add helpers to construct each and type guards to tell them apart:
const left = <E>(value: E): Either<E, never> => ({ tag: "left", value });
const right = <A>(value: A): Either<never, A> => ({ tag: "right", value });
const isLeft = <E, A>(e: Either<E, A>): e is Left<E> => e.tag === "left";
const isRight = <E, A>(e: Either<E, A>): e is Right<A> => e.tag === "right";
One small but elegant detail in those constructor types. right returns Either<never, A>. Why never? As we saw at the start, never is the empty set, the type with no values at all. Saying the Left side is never means "this particular value can never be a Left," which is exactly true for something built by right. TypeScript then happily fits it into any wider Either<E, A> you need. It is a precise way of saying "this is definitely a success, the error side is impossible here."
Rewriting divide2 With Real Errors
Now divide2 can be total and keep its error messages:
const divide2 = (n: number): Either<string, number> => {
if (n === 0) return left("cannot divide by zero");
if (n % 2 !== 0) return left("number is not even");
return right(2 / n);
};
divide2(0); // left("cannot divide by zero")
divide2(3); // left("number is not even")
divide2(4); // right(0.5)
The function is total, every input maps to a real output. And the output type, Either<string, number>, tells the whole truth: this can fail with a string reason, or succeed with a number. No hidden throw. No 500 masquerading as a validation error. The error is a value sitting right there in the type, impossible to ignore.
You May Already Be Doing This
Here is where some of you get a jolt of recognition. In that WordShot rebuild, after the 2am incident, I stopped throwing exceptions for expected failures and made every service method return a small object: either { success: true, data: T } or { success: false, error: string }. I called it the ServiceResult pattern. Every caller had to check .success before touching .data, and TypeScript enforced it.
That is Either. I reinvented a slightly clunkier Either with friendlier field names, because the underlying idea is so natural that working engineers stumble into it on their own. Right is { success: true, data }. Left is { success: false, error }. If you have ever returned a result object with a success flag instead of throwing, you have already felt why Either exists. This part just hands you the real, composable version of the thing you invented under deadline pressure.
The reason this matters at scale: in a system with real users, errors are not exceptional. Players type words not in the dictionary. They pick rare letters. They submit too fast. Those are normal operation, not catastrophes. Modeling them as thrown exceptions treats the ordinary as a crisis. Modeling them as Either values treats them as what they are: data, flowing through your pipeline, handled in plain sight.
The Railway: Collapsing the Pyramid of Doom
Now the real payoff, and the reason Either is beloved. Picture a typical handler that validates and processes an input with several steps that can each fail. Written defensively, it becomes a pyramid:
function processLoanInput(raw: RawInput) {
const parsed = parse(raw);
if (!parsed) return { error: "could not parse" };
const validated = validateAmount(parsed);
if (!validated) return { error: "invalid amount" };
const enriched = attachCustomer(validated);
if (!enriched) return { error: "unknown customer" };
const decision = score(enriched);
if (!decision) return { error: "scoring failed" };
return { ok: decision };
}
Every step is followed by a guard. The happy path is buried under error checks. Add a step, add another if. This nesting is where bugs hide, because it is hard to see the one path that matters through all the defending.
Either gives us a different shape, often called the railway. Imagine two parallel train tracks: a success track on top, an error track on the bottom. Each step is a function that takes a success value and returns an Either. As long as everything succeeds, you stay on the top track, moving forward. The instant any step returns a Left, you switch to the bottom track and ride it straight to the end, skipping every remaining step. No more checking. The first error short-circuits the rest automatically.
The chaining operation that does this is usually called chain (or flatMap). We will define it properly when we reach Monads later in the series, but the shape is what matters now. Each step is written assuming the previous one succeeded, and the plumbing handles the error path for you:
// each step: takes a good value, returns Either<string, NextThing>
const parse = (raw: RawInput): Either<string, Parsed> => ...;
const validateAmount = (p: Parsed): Either<string, Valid> => ...;
const attachCustomer = (v: Valid): Either<string, Enriched> => ...;
const score = (e: Enriched): Either<string, Decision> => ...;
// conceptually, the railway:
// parse -> chain(validateAmount) -> chain(attachCustomer) -> chain(score)
// stays on the success track, or bails to the first Left and stops.
The happy path reads as a straight line of intent. The error handling is no longer scattered through the body, it is structural. Count what changed against the pyramid version: the branch count drops, the number of tests you need drops with it (you test each step once, plus that the railway short-circuits), and a whole class of bug disappears, the "forgot one of the guards" bug, because there are no manual guards left to forget.
Reading the Result: fold
At the very end of the railway, you have an Either<string, Decision> and you need to do something with both cases. The clean way is a fold (sometimes match): give it one function for the Left and one for the Right, and it picks the right branch and produces a final value.
const fold =
<E, A, R>(onLeft: (e: E) => R, onRight: (a: A) => R) =>
(e: Either<E, A>): R =>
isLeft(e) ? onLeft(e.value) : onRight(e.value);
// turn the result into an HTTP response, handling both tracks in one place
const toResponse = fold(
(error: string) => ({ status: 400, body: { error } }), // the Left track
(decision: Decision) => ({ status: 200, body: decision }) // the Right track
);
Both outcomes are handled in one spot, explicitly, with the compiler making sure you covered both. No outcome can slip through.
But Everything Has a Cost
The honest downsides, as promised every part.
Either is more verbose than throwing. Returning a wrapped value and checking it is more code than a bare throw. For genuinely exceptional, unrecoverable situations (out of memory, a programming bug, a truly impossible state), an exception is still the right tool. Either is for expected failures, the validation errors and missing records that are part of normal operation. Do not Either-wrap a bug you cannot recover from.
Resist over-engineering the effect. There are heavier approaches in the ecosystem (full effect systems like ZIO-style tracking) that thread every possible error type through elaborate machinery. For the typical backend, that is usually more than you need. A pure core that returns Either at its seams, with an honest impure shell around it, is the high-return version for most teams. Reach for the heavy machinery only when you have felt the specific pain it solves.
TypeScript's exhaustiveness has limits. The discriminated tag on Left and Right lets the compiler narrow nicely, but TypeScript will not force you to handle every error variant the way a stricter language might. Disciplined fold usage and a never exhaustiveness check (more on that next part) carry the weight.
The Honest Conclusion
Exceptions make your function signatures lie, and that lie is what turns an ordinary validation failure into a 2am page about a 500 error. Either tells the truth instead. By returning Either<Error, Success>, a function admits in its very type that it might fail and how, so the failure becomes a value you carry, see, and handle in plain sight rather than a hidden bomb.
The railway is the reward: chain your steps, stay on the success track, and let the first error short-circuit cleanly to the end. The pyramid of guards collapses into a straight line of intent, fewer branches, fewer tests, and the "forgot a check" bug simply gone.
And if you have ever returned a { success, data, error } object instead of throwing, congratulations, you already discovered the shape of Either under deadline pressure. This is the real, composable version.
An error you can see in the type signature is an error you will never get paged for at 2am. Put your failures in the open, where the compiler can keep you honest.
Next up: We go after the bug where an order is somehow both unpaid and already shipped, a state that should never exist. Next we use algebraic data types and pattern matching to make illegal states impossible to even construct, so the compiler refuses to let you write the bug.