Prisma
@unthrown/prisma is a Prisma Client extension that bridges queries into unthrown's AsyncResult. Applying it with $extends adds try-prefixed variants of the model delegate operations alongside the raw promise ones — each returns an AsyncResult whose error channel is exactly the set of failures that operation can produce, mapped to tagged errors.
pnpm add @unthrown/prisma unthrownimport { unthrownPrisma } from "@unthrown/prisma";
import { PrismaClient } from "./generated/prisma/client.ts";
const db = new PrismaClient({ adapter }).$extends(unthrownPrisma);
const users = db.user.tryFindMany({ select: { id: true } });
// ^? AsyncResult<{ id: number }[], DriverError>Qualification happens once, inside the extension (Boundaries): no raw Promise — and so no un-triaged rejection — ever reaches your code. select / include payload inference survives the wrap, so the success type is still narrowed by your query.
Per-operation error unions
The point of the bridge isn't one flat error type — it's that each operation's error channel is only what that operation can actually raise. A read cannot fail with a UniqueConstraintViolation in the type, so you never write a handler for a case that can't happen.
| Method | Error channel |
|---|---|
tryFindMany / tryFindUnique / tryFindFirst / tryCount / tryAggregate / tryGroupBy | DriverError |
tryFindUniqueOrThrow / tryFindFirstOrThrow | RecordNotFound | DriverError |
tryCreate / tryCreateMany / tryCreateManyAndReturn | UniqueConstraintViolation | ForeignKeyViolation | DriverError |
tryUpsert | UniqueConstraintViolation | ForeignKeyViolation | DriverError |
tryUpdate | RecordNotFound | UniqueConstraintViolation | ForeignKeyViolation | DriverError |
tryUpdateMany / tryUpdateManyAndReturn | UniqueConstraintViolation | ForeignKeyViolation | DriverError |
tryDelete | RecordNotFound | ForeignKeyViolation | DriverError |
tryDeleteMany | ForeignKeyViolation | DriverError |
tryPaginate(...).withCursor(...) | DriverError |
Note where RecordNotFound does not appear: tryUpsert (a miss creates) and the batch mutations (*Many — zero matches is Ok({ count: 0 }), not an error). The union tells you exactly which outcomes are worth a branch.
The tagged errors map to Prisma's P-codes: UniqueConstraintViolation is P2002 (and carries the offending fields), ForeignKeyViolation is P2003, and RecordNotFound is P2025. Everything else — connection drops, timeouts, unmapped codes, non-Prisma causes — folds into DriverError with the original cause preserved.
Absence is not an error
tryFindUnique returns Ok(null) for a miss — a missing row is an anticipated value, not a failure. Reach for tryFindUniqueOrThrow when the absence is the error you want to model (RecordNotFound).
Handling the errors
Because the errors are tagged, matchTags gives you an exhaustive fold — the compiler lists exactly the cases the operation can hit:
import { matchTags } from "unthrown";
const created = await db.user.tryCreate({ data: { email, name } });
return matchTags(created, {
Ok: (user) => resp.created(user),
UniqueConstraintViolation: (e) => resp.conflict(`taken: ${e.fields.join(", ")}`),
ForeignKeyViolation: () => resp.badRequest("unknown reference"),
Defect: (cause) => resp.serverError(cause),
});
// No RecordNotFound arm — a create can't raise it, and the type knows.Or handle it inline with .match({ ok, err, defect }) when you don't need per-tag branches — one call at the HTTP edge, no try/catch.
Transactions
$tryTransaction runs an interactive transaction whose callback speaks AsyncResult. An Err anywhere in the chain triggers a ROLLBACK and comes back out as the same typed error; the try* methods are available on the transaction client tx:
const moved = await db.$tryTransaction((tx) =>
tx.account
.tryUpdate({ where: { id: from }, data: { balance: { decrement: amount } } })
.flatMap(() =>
tx.account.tryUpdate({ where: { id: to }, data: { balance: { increment: amount } } }),
),
);
// ^? AsyncResult<Account, RecordNotFound | UniqueConstraintViolation | ForeignKeyViolation | DriverError>
// Any Err → both updates rolled back, and the Err is in `moved`.- An
Errfrom the callback rolls back and re-surfaces as that same modeled error. - A
Defectalso rolls back and stays a defect — including a callback that throws instead of returning anAsyncResult. A bug is never quietly downgraded into your error channel. - Nesting is a compile error:
txhas no$tryTransaction. For a batch of independent writes, use the raw$transaction([...])with the raw (unexecuted) promise methods.
Cursor pagination
tryPaginate(query).withCursor(...) follows the prisma-extension-pagination cursor API — same option names, same [results, meta] shape:
const page = await db.user
.tryPaginate({ where: { active: true }, orderBy: { id: "asc" } })
.withCursor({ limit: 20, after: req.query.cursor });
// ^? AsyncResult<[User[], CursorPaginationMeta], DriverError>
page.match({
ok: ([users, meta]) => json({ users, nextCursor: meta.endCursor, hasMore: meta.hasNextPage }),
err: (e) => serverError(e),
defect: serverError,
});meta carries hasPreviousPage / hasNextPage / startCursor / endCursor. The query args exclude cursor / take / skip (pagination owns them), and payload inference still flows through select / include.
Three deliberate differences from upstream:
- A cursor pointing at a now-filtered-out row doesn't skip the first element of the page (folds in the fix for deptyped/prisma-extension-pagination#35).
before+limit: nullis a compile error — Prisma's negativetakecan't express "everything before the cursor, unbounded."- The default cursor preserves the id's type — an all-digit cursor parses back to a number (autoincrement ids), promoted to a
bigintbeyondNumber.MAX_SAFE_INTEGERso aBigIntid never loses precision; anything else stays a string (uuid / cuid). ProvidegetCursor/parseCursorfor composite keys or a selection withoutid.
The raw methods stay on purpose
The bridge is additive: db.user.findMany(...) (the raw promise) is still there. Every model delegate operation has a try* variant, so the raw methods remain for exactly two things — batch $transaction([...]) (which needs unexecuted PrismaPromises) and raw SQL ($queryRaw / $executeRaw). Qualify those yourself at the boundary with fromPromise:
import { fromPromise } from "unthrown";
import { qualifyPrismaError } from "@unthrown/prisma";
// qualifyPrismaError is exported — reuse the same P-code triage for a raw call.
const rows = fromPromise(db.$queryRaw`SELECT 1`, qualifyPrismaError);
// ^? AsyncResult<unknown, UniqueConstraintViolation | ForeignKeyViolation | RecordNotFound | DriverError>See the API reference for every method's exact signature.