oRPC
@unthrown/orpc bridges oRPC (v2) and unthrown in both directions: procedure handlers that return a Result on the server, and a client whose every call yields an AsyncResult — with oRPC's end-to-end typed errors as the modeled error channel.
pnpm add @unthrown/orpc unthrownoRPC is an unusually good fit for unthrown, because its error model already agrees with the thesis: an error a procedure declares (.errors({...})) or returns as a value is inferable — typed end-to-end, recognisable at runtime — while everything else is collapsed to INTERNAL_SERVER_ERROR. That is exactly the Err / Defect split:
| unthrown | oRPC v2 |
|---|---|
Ok(value) | the procedure's output |
Err(error) | a returned ORPCError — inferable, typed E2E |
Defect | everything else (INTERNAL_SERVER_ERROR) |
Qualification happens once, inside the bridge (Boundaries): the triage decision was already made when the procedure declared (or returned) its errors, so no per-call qualify is asked of you.
oRPC v2
The package targets oRPC v2 (in beta at the time of writing; peer range ^2.0.0-beta), whose returned-ORPCError inference is what the server half builds on. Its majors track oRPC's cadence, not the unthrown family's.
Server: handlers that return a Result
handlerResult adapts a Result-returning handler into a plain oRPC handler — your service layer keeps speaking Result, and the endpoint stops needing to unwrap it into throws:
import { handlerResult } from "@unthrown/orpc/server";
import { os } from "@orpc/server";
import * as z from "zod";
const find = os
.input(z.object({ id: z.string() }))
.errors({ NOT_FOUND: {} })
.handler(
handlerResult(({ input, errors }) =>
repo.findPlanet(input.id).mapErr(() => errors.NOT_FOUND()),
),
);Okbecomes the procedure's output.Erris returned as a value; oRPC marks it inferable, so the client sees it fully typed. The error channel is constrained toORPCError— themapErrthat turns a domain error into one (hereerrors.NOT_FOUND(), the constructor oRPC injects) is the explicit triage point at the transport boundary.- A
Defectrethrows its original cause, which oRPC collapses toINTERNAL_SERVER_ERROR. A bug stays a defect — it never becomes a typed error your client is invited to handle.
A returned ORPCError needs no .errors({...}) declaration at all — v2 infers it from the handler's type:
const limited = os.handler(
handlerResult(({ input }) =>
tooMany(input)
? Err(new ORPCError("RATE_LIMITED", { data: { retryAfter: 60 } }))
: Ok("welcome"),
),
);
// the client's error channel: ORPCError<"RATE_LIMITED", { retryAfter: number }>The handler may be synchronous, async, or return an AsyncResult directly — an elimination edge is exempt from the no-thenable rule (same as match handlers): a throw or rejection inside it cannot skip triage, because oRPC's own boundary already treats it as the defect path.
The .result() builder extension
If you prefer a builder method over wrapping, opt into the extension — one side-effectful import (the packaging @orpc/experimental-effect uses for .effect()):
import "@unthrown/orpc/extensions/result";
const find = os
.input(z.object({ id: z.string() }))
.errors({ NOT_FOUND: {} })
.result(({ input, errors }) => repo.findPlanet(input.id).mapErr(() => errors.NOT_FOUND()));It is available on every builder state (os, after .use, .input, .output, .errors) and on contract-first implement(...) implementers, and is runtime-identical to .handler(handlerResult(...)). Everything else in the package is side-effect-free; reach for handlerResult when patching a third-party prototype is unwelcome.
Client: calls that return an AsyncResult
createResultClient wraps an oRPC client so every procedure returns an AsyncResult — the mirror of oRPC's own createSafeClient, producing Results instead of [error, data, inferableError] tuples:
import { createResultClient } from "@unthrown/orpc/client";
const rc = createResultClient(client);
const greeting = await rc.planet
.find({ id })
.map((planet) => `Hello, ${planet.name}!`)
.match({
ok: (msg) => msg,
err: (e) => (e.code === "NOT_FOUND" ? "Hello, void!" : "Hello, trouble!"),
defect: () => "Hello, bug tracker!",
});The error channel is the raw inferable ORPCError union, discriminated by code — deliberately not re-wrapped into tagged errors: oRPC already ships a discriminated error type, and one concept should have one name. Branch on code (a switch, ts-pattern, or the .errors data types); matchTags does not apply to this package.
Anything non-inferable — a network failure, an undeclared throw collapsed to INTERNAL_SERVER_ERROR, a malformed response — is a Defect: it flows past your error combinators and panics at get, because it is a bug (or an outage), not an outcome your domain models.
fromCall is the one-shot form, and also lifts oRPC's server-side call(procedure, input):
import { fromCall } from "@unthrown/orpc/client";
import { call } from "@orpc/server";
const planet = await fromCall(client.planet.find({ id })); // a client call
const seeded = await fromCall(call(find, { id: "1" })); // a server-side callCall options (signal, context, lastEventId) pass through untouched.
Streaming is out of scope
Event-iterator procedures don't collapse to one Result — modelling a stream's per-event and terminal failures is its own design. Call those on the raw client.
End to end
Both halves compose into one error vocabulary across layers — a Prisma-backed service chains into an oRPC handler, and the browser consumes it, all in Result:
// server — the one mapErr is the whole edge: each domain error is either given
// a client-facing code or declared not the client's business (a 500).
const createUser = os
.input(z.object({ email: z.string() }))
.errors({ EMAIL_TAKEN: {} })
.handler(
handlerResult(({ input, errors }) =>
db.user
.tryCreate({ data: input })
.mapErr((e) =>
e._tag === "UniqueConstraintViolation"
? errors.EMAIL_TAKEN()
: new ORPCError("INTERNAL_SERVER_ERROR", { cause: e }),
),
),
);
// client
const outcome = await rc.createUser({ email });
if (outcome.isErr() && outcome.error.code === "EMAIL_TAKEN") {
form.setError("email", "already registered");
}