Typed Errors Without try/catch
Oleksandr Zhuravlov
You write a loader that fetches a user, and the failure path is a try/catch whose catch (err) binds err as unknown. To read the HTTP status you re-narrow — if (err instanceof StitchError) — every single time, in every loader, because TypeScript cannot prove what throw threw. The happy path and the failure path live on opposite sides of a control-flow wall, and the type system has lost track of the error before you ever touch it.
A stitch gives you the other shape. Awaiting a stitch throws on failure — the contract a queryFn wants — but calling it with .safe() resolves to a discriminated union instead, where the failure arm is already a StitchError and the success arm is already your validated type. You branch on a boolean; the compiler narrows both arms for you.
The unknown you keep re-narrowing
Here is the honest "before" — fetch, a hand-written status check, and a catch that has to reconstruct what it caught:
async function loadUser(id: string) {
try {
const res = await fetch(`https://api.example.com/users/${id}`);
if (!res.ok) {
throw new StitchError('getUser failed', { status: res.status });
}
const user = (await res.json()) as User; // unchecked cast
return { heading: user.name };
} catch (err) {
// err: unknown — narrow before you can read .status
if (err instanceof StitchError && err.status === 404) {
return { error: 'No such user' };
}
return { error: 'Something went wrong' };
}
}Three sharp edges, all real. The catch parameter is unknown (TypeScript has typed it that way since 4.4), so the first thing every handler does is re-prove the error's shape before it can branch on a status. The success value is an unchecked as User cast — the JSON could be anything and the compiler believes you. And the two outcomes straddle a try/catch boundary, so the control flow that produced the value and the control flow that handles its failure never meet in one expression.
.safe() returns a union, not a throw
Declare the call as a stitch with an output schema, then call it with .safe(). The result is a SafeResult<T> you branch on — no try, no catch, no re-narrowing:
import { stitch } from 'stitchapi';
import { z } from 'zod';
const User = z.object({ id: z.string(), name: z.string() });
const getUser = stitch({
baseUrl: 'https://api.example.com',
path: '/users/{id}',
output: User,
});
async function loadUser(id: string) {
const result = await getUser.safe({ params: { id } });
if (!result.ok) {
// result.error: StitchError — already narrowed, .status is here
if (result.error.status === 404) return { error: 'No such user' };
return { error: 'Something went wrong' };
}
// result.data: User — validated, not cast
return { heading: result.data.name };
}.safe() never throws. It resolves to a value whose ok flag the compiler uses to narrow both arms: in the !result.ok branch result.error is a StitchError with .status in reach and no instanceof guard; in the other branch result.data is a User that the output schema actually validated, not an as cast you hoped was right. The status check that needed a re-narrow before now reads straight off the failure arm.
The shape of the union
SafeResult<T> is a discriminated union, and every member carries all four fields so the discriminant is exhaustive on both arms:
type SafeResult<T> =
| { ok: true; data: T; error: null }
| { ok: false; data: null; error: StitchError };Branch on ok (or check error === null) and the union collapses to one arm. The StitchError on the failure side is the same error await would have thrown, populated on the .safe() path: .status (the HTTP status, or undefined for a transport or internal error), .attempts (tries made before giving up — 1 means no retry fired), .body (the parsed error payload, an API's { error: "..." }), and .url (the final request URL). That error is built after the stitch exhausts its own retry policy, so a 404 on the failure arm is a settled 404, not a transient blip the stitch would have retried.
The two extra doors
.safe() is one of four ways to consume the same single run. The stitch executes once; then, catch, finally, and safe all share that run rather than firing it again:
const user = await getUser({ params: { id } }); // throws StitchError on failure
const safe = await getUser.safe({ params: { id } }); // { ok, data, error }, never throws
const user2 = await getUser.unwrap({ params: { id } }); // the throwing twin of .safe()
const user3 = await getUser({ params: { id } }).catch((err) => fallbackUser); // err: unknown.unwrap() is the explicit spelling of the bare await — it throws, same as awaiting the stitch directly. .catch() mirrors Promise.catch: its handler parameter is typed unknown, exactly like a try/catch binding, so reading .status inside it still costs you an instanceof StitchError narrow. That is the contrast worth naming: .catch() keeps the unknown and the re-narrow; .safe() is the door that hands you the union with the error already typed. Reach for the throwing call where a throw is the contract — a TanStack Query queryFn, which wants a rejected promise — and for .safe() where you would rather branch than catch.
The split between the two:
| Axis | await / .unwrap() | .safe() |
|---|---|---|
| On failure | throws a StitchError | resolves { ok: false, error } |
| Error type at use | unknown until you narrow | StitchError on the failure arm |
| Success value | the validated T | data: T on the success arm |
| Where it fits | a queryFn, a throw-is-contract path | a loader, a server action |
Branch where the call is, not in a catch
A try/catch puts the failure handler in a different block from the call, and TypeScript forgets the error's type on the way across. .safe() keeps both outcomes in one expression: the call returns a value, you branch on .ok, and each arm is already narrowed — the failure arm to StitchError, the success arm to the schema-validated T. There is no separate code path to keep in sync and no unknown to re-prove.
The same stitch serves both shapes from one declaration. Where a throw is the contract you await it; where a branch reads cleaner you call .safe(); the run underneath is the same, and so are the output validation, the auth, and the retry that settled the status before it reached you. Add the next endpoint and it is the same output schema and the same two doors — no per-call error plumbing to grow.
Try it
Install the core package. stitch, StitchError, and the SafeResult shape all live in the main stitchapi entry.
npm install stitchapi@rcThen call .safe() for the union, or await (or .unwrap()) where a throw is the contract.