throw is a control-flow tool borrowed from languages that never had a good way to say
"this function can fail" in its own signature. TypeScript does have that way — a union type —
and the Either pattern is what happens when you take that seriously for domain errors.
The problem with throw
A function signature like this is lying to you:
function createSlug(raw: string): Slug {
if (!isValid(raw)) throw new Error('Invalid slug');
return new Slug(raw);
}Nothing in Slug tells the caller that this function can fail. The only way to find out is to
read the implementation, or find out in production when nobody wrapped the call in try/catch.
Either as the alternative
Either<L, R> is a container that's always in one of two states: Left (failure) or
Right (success). The caller is forced to check which one they got before touching the value:
type Either<L, R> = Left<L, R> | Right<L, R>;
class Left<L, R> {
constructor(readonly value: L) {}
isLeft(): this is Left<L, R> { return true; }
isRight(): this is Right<L, R> { return false; }
}
class Right<L, R> {
constructor(readonly value: R) {}
isLeft(): this is Left<L, R> { return false; }
isRight(): this is Right<L, R> { return true; }
}
export const left = <L, R>(v: L): Either<L, R> => new Left(v);
export const right = <L, R>(v: R): Either<L, R> => new Right(v);And a create factory that returns one instead of throwing:
class Slug {
private constructor(readonly value: string) {}
static create(raw: string): Either<ValidationError, Slug> {
if (!isValid(raw)) {
return left(new ValidationError({ code: 'INVALID_SLUG' }));
}
return right(new Slug(raw));
}
}Now the compiler enforces the check:
const result = Slug.create('my-project');
if (result.isLeft()) {
// result.value is ValidationError here — narrowed, no cast needed
return;
}
// result.value is Slug hereWhat this buys you
- The signature tells the truth.
Either<ValidationError, Slug>says, in the type itself, that this can fail and with what. - No silent unhandled exceptions. A forgotten
try/catcharound a throwing function fails at runtime, in production. A forgottenisLeft()check fails at compile time, on your machine. - Errors compose. Chaining several
Either-returning calls (via acollect/chainhelper) short-circuits on the first failure, the same wayPromisechains short-circuit on the first rejection — but for synchronous domain validation.
throw isn't banned everywhere — it's still fine for truly exceptional, unrecoverable states
(a missing environment variable, a broken invariant). What it's not fine for is business-rule
validation, which is not exceptional at all: an invalid slug is an expected, everyday outcome
that the type system should know about.
