TypeScript describes what a function returns, but not what it throws. A familiar function:
type Post = { id: string; title: string; body: string };
async function getPost(id: string): Promise<Post> {
const response = await fetch(`https://api.example.com/posts/${id}`);
if (response.status === 404) throw new Error("Post not found");
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as Post;
}
The signature promises a Post, but fetch can reject when the network drops, the status guards can throw, and json() can throw on an invalid body. None of those failures appears in the types, so nothing makes the caller handle them.
A common fix is to model every failure as a return value, by hand or with a Result library. You get the contract in the signature, but give up plain return and throw — a failure inside a callback has to be returned and propagated by hand.
error-san keeps what throw gets right — it's concise, it stops execution even from inside a callback, TypeScript narrows around it — and adds the missing piece: a typed contract with a payload you can match on. Raise inside the function; a wrapper catches at the boundary and returns the outcome as data.
Declare, raise, done
The same function with error-san:
import { type Errors, UnexpectedError, wrapAsync } from "error-san";
type Post = { id: string; title: string; body: string };
const getPost = wrapAsync(
async (
errors: Errors<{
ConnectionError: { message: string };
NotFoundError: void;
}>,
id: string,
) => {
const response = await fetch(`https://api.example.com/posts/${id}`).catch(
(cause) => errors.ConnectionError({ message: String(cause) }),
);
if (response.status === 404) errors.NotFoundError();
if (!response.ok) {
errors.UnexpectedError(new Error(`HTTP ${response.status}`));
}
return (await response.json()) as Post;
},
);
Callers get every outcome back as data:
const result = await getPost("42");
if (result.isOk) {
console.log(result.data.title);
} else {
switch (result.code) {
case "ConnectionError":
console.error(result.reason.message);
break;
case "NotFoundError":
console.error("Post not found");
break;
case "UnexpectedError":
throw result.reason;
}
}
Four things to notice:
-
Errors<{ ... }>lists what can go wrong, right next to the code that can raise them. Every key becomes a raiser and every value is the reason that error carries.errors.NotFoundError()behaves like a typedthrow, anderrors.UnexpectedError(reason)is always available without being declared — which is why!response.okdoesn't need a code of its own. - Raisers return
never, so TypeScript knows execution stops there, just as with a regularthrow. Guard clauses still narrow types, and the.catch(...)callback still gives you a plainResponse. -
wrapAsynctakes care of the boundary: it hides theerrorsparameter from callers, catches whatever the function raises, and returns it as data. Anything else thrown or rejected — a failingjson(), a bug you haven't anticipated — becomesUnexpectedError. That branch is in every result, whether you declare anything or not. - Checking
codenarrowsreasonto the payload declared for that error, and autocomplete shows all three cases: the two declared errors and the built-inUnexpectedError.
A raiser is an expression, so unlike a bare throw it works inline. Its never return vanishes from the union, so TypeScript infers Post:
const post = posts.find((p) => p.id === id) ?? errors.NotFoundError();
// post: Post — not Post | undefined
One expression, every case
The switch works, but there's also an exhaustive handler map:
const post = result.unwrap({
ConnectionError: (reason) => {
console.error(reason.message);
return null;
},
NotFoundError: () => null,
UnexpectedError, // rethrow
});
// Post | null
Miss a case: type error. Invent one: type error. Add another error to getPost six months from now and every handler map that hasn't caught up stops compiling — which is the whole point. A plain throw gives you nothing like this.
If you've used Rust's Result, neverthrow, or Effect, the idea will be familiar. The difference is the function body: no ok() or err() wrapped around every return, no generators, no chaining. One extra parameter aside, it's the code you were already writing.
What's left, and what's left out
The whole library is four exports and a handful of methods, and you've seen three of the exports already. The rest, in brief — the README has the details:
-
wrapSync— same idea aswrapAsync, for synchronous functions -
wrapAsync.tryandwrapSync.try— for functions that declare no custom errors -
result.handle({ ... })— when you want to recover from some errors instead of all of them
It's deliberately not a full Result framework: no map / andThen chains. Errors don't automatically flow from one wrapped function into another either — if A calls B, A declares and re-raises whatever it wants to pass along. The trade: slightly more typing, a lot less magic.
One performance note: raising costs more than returning a value, because it captures a stack trace. That rarely matters, but measure it if a raiser sits on a hot path.
Try it
error-san is under 1 kB minified and gzipped, has no runtime dependencies, and runs in modern browsers, workers, and Node.js 22+.
It needs TypeScript 7 or newer with strict on.
npm install error-san
Top comments (0)