Skip to content

Use with Prisma

How-to. @unthrown/prisma is a Prisma Client extension that bridges queries into an AsyncResult whose error channel is exactly the failures that operation can raise.

sh
pnpm add @unthrown/prisma unthrown

Apply the extension

$extends(unthrownPrisma) adds try-prefixed variants of the model delegate operations alongside the raw promise ones:

ts
import { 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 }[], never>

Qualification happens once, inside the extension — 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

E carries only domain outcomes — things a caller did, or a legitimate state conflict, that your code actually branches on. Each operation's channel is exactly what that operation can raise, so you never write a handler for a case that can't happen:

MethodError channel
tryFindMany / tryFindUnique / tryFindFirst / tryCount / tryAggregate / tryGroupBynever
tryFindUniqueOrThrow / tryFindFirstOrThrowRecordNotFound
tryCreate / tryUpsert / tryUpdateUniqueConstraintViolation | ForeignKeyViolation | RecordNotFound
tryDeleteForeignKeyViolation | RecordNotFound
tryCreateMany / tryCreateManyAndReturnUniqueConstraintViolation | ForeignKeyViolation
tryUpdateMany / tryUpdateManyAndReturnUniqueConstraintViolation | ForeignKeyViolation
tryDeleteManyForeignKeyViolation
tryPaginate(...).withCursor(...)InvalidCursor

UniqueConstraintViolation is P2002 (a 409, and it carries the offending fields), ForeignKeyViolation is P2003 (a 400), and RecordNotFound is P2025 — plus P2018, which says the same thing from the to-many side of a nested write (a 404).

Those four codes are the whole modeled set

P2002, P2003, P2018, P2025 — and nothing else. Every other P-code becomes a Defect, including ones that read like domain outcomes: P2007 (malformed value / inconsistent column data), P2023 (inconsistent column data), P2000 (value too long), P2011 (null constraint), P2015 (related record not found). The table above is a boundary, not a menu.

That matters wherever a defect is retried rather than surfaced. Folded at an HTTP edge, a defect is a 500 and the request is over. Inside a Temporal activity — or any supervisor that retries on a thrown error — an Err you convert to a non-retryable failure fails fast, while a defect is rethrown and retried. A P2007 from a malformed id can never succeed on a retry, so a path that carries that id (an onError handler, a dead-letter reprocessor) retries forever. Re-qualify such a code explicitly — see below.

A read has no modeled failure at all. Absence is null, and a database that will not answer is a defect — so tryFindMany is AsyncResult<User[], never>.

Note where RecordNotFound does not appear: the batch mutations (*Many and their *AndReturn twins). Those are the only operations genuinely free of it — they accept no nested writes, and zero matches is Ok({ count: 0 }), not an error.

tryCreate and tryUpsert do carry it, which is worth a second look: neither has a row of its own to miss, but a nested connect does.

ts
db.post.tryCreate({ data: { title, author: { connect: { id: authorId } } } });
// Err(RecordNotFound) when that author does not exist — P2025.

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).

Everything infrastructural is a defect

A dropped connection, a pool timeout, a deadlock, an unmapped P-code, a malformed query, a client that could not start, an engine panic — none of those reaches your error channel. They go to the defect channel, with the original cause preserved.

That is not a demotion. A defect is not a crash: it flows through the pipeline untouched and is folded at the edge by match's defect handler, exactly where you already turn unexpected failures into a 500. The channel means "not worth threading through domain code", not "fatal".

The test is simple — would you branch on it? You genuinely handle a duplicate email (409) or a missing parent row (404). You do not write domain logic for a severed TCP connection; you log it and return a 500. Modelling it would only force every call site to carry an arm that does the same thing as the defect arm sitting right beside it:

ts
// What modelling infrastructure failures would cost you, at EVERY call site:
errCases: (matcher) => matcher
  .with(P.tag("UniqueConstraintViolation"), (e) => resp.conflict(e.fields))
  .with(P.tag("DriverError"), (e) => resp.serverError(e)),   // ← this
defect: (cause) => resp.serverError(cause),                   // ← and this

The one carve-out: pagination cursors

A cursor is an opaque string from the outside world, turned into a query by your parseCursor. A client sending garbage is anticipated input you answer with a 400 — so it is modeled, as InvalidCursor. A throw out of getCursor (which reads rows you fetched) is a bug, and stays a defect. See Cursor pagination below.

Retries

Deadlocks (P2034) and pool timeouts (P2024) are defects too, so a retry wrapper reaches for recoverDefect and inspects the cause, rather than matching a tag. That is one place in a codebase — versus an arm at every call site.

Migrating a hand-rolled qualifier

Replacing your own try/catch qualifier with try* is not a like-for-like swap: diff your old qualifier against the four modeled codes, because anything else it used to turn into an Err now lands on the defect channel.

The migration is silent by every signal you would normally trust — the type check passes (the Err channel legitimately shrank), and a repository unit test asserting the Ok path passes too. What changes is the behaviour of the failure path, one layer up.

Re-qualify the codes you were modelling with recoverDefect, right where the query is issued:

ts
import { RecordNotFound } from "@unthrown/prisma";
import { Err, type AsyncResult } from "unthrown";

// Recognized by `name` + a string `code`, not `instanceof`: Prisma's runtime
// module path moves between majors, and an `instanceof` against the wrong copy
// silently fails. This is the same check the extension itself uses.
const hasCode = (cause: unknown, code: string): boolean =>
  cause instanceof Error &&
  cause.name === "PrismaClientKnownRequestError" &&
  (cause as { code?: unknown }).code === code;

const findUser = (id: string): AsyncResult<User, RecordNotFound> =>
  db.user.tryFindUniqueOrThrow({ where: { id } }).recoverDefect((cause) => {
    // A malformed id is the same domain outcome as a missing row — and it can
    // never succeed on a retry, so it must not stay a defect.
    if (hasCode(cause, "P2007")) return Err(new RecordNotFound({ cause }));
    // oxlint-disable-next-line unthrown/no-throw -- rethrow keeps the defect intact
    throw cause;
  });

The throw cause is caught by the pipeline's own throw-to-defect net, so everything you did not name stays a defect with its original cause — no try/catch, and nothing untriaged leaks into E. Keep the comment: without it, the next reader "simplifies" the branch away.

Re-qualifying into the error the operation already models — RecordNotFound here — keeps E unchanged, so no call site has to grow an arm. A code that is genuinely a different outcome gets your own tagged error, and every exhaustive match on that channel stops compiling until it is handled, which is the point.

Handle the errors

Because the errors are tagged, driving match's errCases handler with the matcher gives you an exhaustive fold — the compiler lists exactly the cases the operation can hit:

ts
import { P } from "unthrown";

const created = await db.user.tryCreate({ data: { email, name } });

return created.match({
  ok: (user) => resp.created(user),
  errCases: (matcher) =>
    matcher
      .with(P.tag("UniqueConstraintViolation"), (e) =>
        resp.conflict(`taken: ${e.fields.join(", ")}`),
      )
      .with(P.tag("ForeignKeyViolation"), P.tag("RecordNotFound"), () =>
        resp.badRequest("unknown reference"),
      ),
  defect: (cause) => resp.serverError(cause),
});
// Exactly the three cases a create can raise — no more, no fewer. Add a case to
// the union and every call site like this one stops compiling until it is
// handled. Everything else (a dropped connection, a deadlock, a bug) lands in
// the one `defect` arm.

When several tags deserve the same response, group them in one arm rather than reaching for a wildcard — the list stays explicit, so a new P-code still lights the call site up:

ts
matcher.with(
  P.tag("UniqueConstraintViolation"),
  P.tag("ForeignKeyViolation"),
  P.tag("RecordNotFound"),
  () => resp.badRequest("bad write"),
);

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:

ts
const moved = 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>
// Any Err → both updates rolled back, and the Err is in `moved`.
  • An Err from the callback rolls back and re-surfaces as that same modeled error.
  • A Defect also rolls back and stays a defect — including a callback that throws instead of returning an AsyncResult. A bug is never quietly downgraded into your error channel.
  • Nesting is a compile error: tx has no $tryTransaction.
  • Naming the tx parameter of a helper factored out of the callback is TransactionClient<typeof db> — use it rather than restating the deny list by hand, which drifts silently (Omit of a key that does not exist is not an error):
ts
import type { TransactionClient } from "@unthrown/prisma";

type Tx = TransactionClient<typeof db>;

const chargeFees = (tx: Tx, id: number, fee: number) =>
  tx.account.tryUpdate({
    where: { id },
    data: { balance: { decrement: fee } },
  });

Batch transactions

For independent writes with no application logic between them, $tryTransaction also takes an array — Prisma's batch form, one round trip, all or nothing:

ts
const rows = db.$tryTransaction(inputs.map((data) => db.user.create({ data })));
//    ^? AsyncResult<User[], PrismaQueryError>

A fixed tuple keeps positional types ([db.user.create(…), db.user.count()]AsyncResult<[User, number], …>); a dynamic array collapses to a list, the same duality as core's all.

Two consequences of Prisma's batch form needing unexecuted PrismaPromises, both deliberate:

  • The array holds the raw delegate methods — db.user.create(...), not tryCreate. A try* method has already run and returns an AsyncResult, so passing one is a compile error.
  • E is the whole PrismaQueryError union rather than the per-operation narrowing you get from try*: a raw PrismaPromise carries no error-type information. Infrastructure failures are still defects, exactly as everywhere else.

maxWait and timeout are not accepted — they govern an interactive transaction's open window, which a batch does not have. isolationLevel is.

Cursor pagination

tryPaginate(query).withCursor(...) follows the prisma-extension-pagination cursor API — same option names, same [results, meta] shape:

ts
import { P } from "unthrown";

const page = await db.user
  .tryPaginate({ where: { active: true }, orderBy: { id: "asc" } })
  .withCursor({ limit: 20, after: req.query.cursor });
//    ^? Result<[User[], CursorPaginationMeta], InvalidCursor>

page.match({
  ok: ([users, meta]) =>
    json({ users, nextCursor: meta.endCursor, hasMore: meta.hasNextPage }),
  errCases: (matcher) =>
    matcher.with(P.tag("InvalidCursor"), () => badRequest("bad cursor")),
  defect: serverError,
});

Four deliberate differences from upstream: a cursor pointing at a now-filtered-out row doesn't skip the first element (folds in the fix for deptyped/prisma-extension-pagination#35); after and before are mutually exclusive (a page runs in one direction, and passing both used to silently drop after); before + limit: null is a compile error; and the default cursor preserves the id's type (all-digit → number/bigint, otherwise string). Provide getCursor / parseCursor for composite keys.

A malformed cursor is a modeled InvalidCursor rather than a defect — the one place a Prisma validation error is treated as anticipated input, because the cursor comes from the client rather than from your code. A throw out of getCursor, which reads rows the query just returned, is a bug and stays a defect.

Raw methods and raw SQL

The bridge is additive: db.user.findMany(...) (the raw promise) is still there, for exactly two things — composing the array a batch $tryTransaction([...]) runs, and raw SQL. Qualify raw SQL yourself at the boundary, reusing the exported qualifyPrismaError:

ts
import { fromPromise } from "unthrown";
import { qualifyPrismaError } from "@unthrown/prisma";

const rows = fromPromise(db.$queryRaw`SELECT 1`, qualifyPrismaError);
//    ^? AsyncResult<unknown, UniqueConstraintViolation | ForeignKeyViolation | RecordNotFound>

qualifyPrismaError is a qualify — the boundary injects the defect helper as its second argument, so the same triage you get inside the extension (including routing the three bug-shaped Prisma errors to the defect channel) applies to your own boundaries for free.

See the API reference for every method's exact signature.

Where to go next

Released under the MIT License.