Getting started
Tutorial. A hands-on first lesson. Follow it top to bottom and you'll have written, chained, and folded your first
Results. We keep explanation to a minimum here and link out to it — the goal is to do, not to study.
By the end you will have a small program that parses a value and handles every outcome as a value — with no try/catch to write. It takes about ten minutes.
Step 1 — Install
pnpm add unthrownnpm install unthrownyarn add unthrownunthrown is ESM-first, ships dual CJS/ESM builds with full types, and has zero runtime dependencies — the exhaustive error matcher is built-in (exported as match / P). Use it with TypeScript in strict mode.
Step 2 — Return a failure instead of throwing
A Result<T, E> is either an Ok carrying a value T, or an Err carrying a modeled error E. E lists only the failures you anticipate.
Say you're parsing a user-supplied age. Two things can go wrong. Model both instead of throwing:
import { Ok, Err, type Result } from "unthrown";
type AgeError = "not_a_number" | "negative";
function parseAge(input: string): Result<number, AgeError> {
const n = Number(input);
if (Number.isNaN(n)) return Err("not_a_number");
if (n < 0) return Err("negative");
return Ok(n);
}
parseAge("42"); // => Ok(42)
parseAge("-3"); // => Err("negative")
parseAge("x"); // => Err("not_a_number")Nothing is thrown — both outcomes come back as values. Notice the signature now tells the whole truth: a caller can see that parseAge may fail, and how.
Step 3 — Transform and chain
Success combinators run only on Ok; an Err passes straight through, so you can chain without checking at every step:
const adult = parseAge("42")
.map((n) => n + 1) // => Ok(43) — map: callback returns a plain value
.flatMap((n) => (n >= 18 ? Ok(n) : Err("underage"))); // => Ok(43) — flatMap: callback returns a Result
// adult: Result<number, AgeError | "underage"> — flatMap unioned the error channelsThe value stays wrapped in a Result the whole way — you extract it once, at the edge, in Step 4. Nothing is thrown along the way.
And when a step fails, the rest of the chain is skipped:
const parsed = parseAge("x") // => Err("not_a_number")
.map((n) => n + 1); // callback never runs — still Err("not_a_number")
if (parsed.isErr()) parsed.error; // => "not_a_number"Rule of thumb: reach for map when your callback returns a plain value, flatMap when it returns another Result. (The full picture is in the combinator reference — you don't need it yet.)
Step 4 — Handle every outcome with match
At the edge of your program, fold a Result into a single value with match. You handle three runtime channels — ok, errCases, and a defect channel for the unexpected (you'll meet it in the next step):
const message = parseAge("-3").match({
ok: (age) => `age is ${age}`,
// `errCases` receives an exhaustive matcher — one branch per error. It
// matches plain strings too (no tag required), and a missing case won't compile.
errCases: (matcher) =>
matcher
.with("negative", () => "must be positive")
.with("not_a_number", () => "not a number"),
defect: (cause) => {
console.error(cause); // a bug slipped through — log it, don't leak it
return "something went wrong";
},
});
// => "must be positive"Try deleting the .with("not_a_number", …) branch — your editor will flag it as a compile error. That's the point: the error channel is matched exhaustively, so a new failure can never be silently ignored.
Step 5 — Meet a defect
What happens if a callback throws by accident — a typo, a JSON.parse on bad input? unthrown catches it and turns it into a Defect, a third state that is not part of your error type:
const result = parseAge("42").map((age) => {
throw new Error("boom"); // an unexpected bug
});
result.isDefect(); // => true — not an Err, and not in AgeErrorBecause a thrown bug becomes a defect (never an Err), the single match from Step 4 needs no surrounding try/catch — the defect arm catches everything unexpected. That is the whole promise of the library: modeled failures travel as values, and bugs are quarantined in their own channel.
What you built
You now have a function that:
- returns its failures as typed values instead of throwing;
- chains transformations that skip automatically on failure;
- folds every outcome — success, modeled error, and unexpected bug — in one exhaustive
match.
Where to go next
- Continue the tutorial: Crossing an async boundary — the same ideas, applied to promises.
- Understand the defect channel: The Defect Channel.
- Look up a combinator: Combinator reference.