Every database error in this blog is a value
August 11, 2026
Every error in this blog is a value. A failed query, a missing row, a unique constraint violation: none of them throw. They are values with a tag and a payload, and they travel from the database, across the wire, to the browser and the CLI, until the thing that can act on them folds them into something a person can read.
I write two of the three libraries this post is about. result-rpc is the RPC layer the blog runs on. db-result is the newest one: I wrote it to find out how far upstream an honest Result<T, E> shape can go while keeping the E union trimmed to what a query can actually produce. Underneath both is better-result, dmmulroy's Result type.
The three-outcome model
The rule that keeps this from turning into Result soup lives in one comment in the blog's RPC server:
Handlers declare the failures they know, return happy paths, and let everything else fall through as a Panic that the framework turns into a sanitized
server/internalwith an incident id.
Three outcomes, deliberately. A handler names the failures it can act on: a slug is taken, a revision is stale, a comment is not yours. The happy path just returns. Everything the handler did not think of, a corrupted row, a connection that died mid-query, falls through as a panic and becomes a server/internal tag with an incident id you can grep the Worker log for. The browser and the CLI both know how to render that tag. Nothing reaches the user as a bare stack trace.
The restraint is the point. If every failure needed a tag, the error vocabulary would be a museum of things that never happen. Tag what you can fold into something meaningful, panic the rest, keep the panic honest.
Kysely, storage-faithful
The query builder is Kysely over Cloudflare D1. One decision set the tone for everything: the types match what is actually in the database, not what is pleasant to work with. Timestamps are epoch seconds, not Date. Booleans are 0 | 1, not boolean. Colors are stored as JSON text, not an object. The schema says so, and the row types say so:
export const rawDb = new Kysely<DB>({ dialect: new D1Dialect({ database: env.DB }) });
export const db = kyselyTryDb<typeof rawDb, SqliteDbError>(rawDb);Decoding happens in one place, at the edge, in decodePost and its siblings. A row of epoch seconds becomes a StoredPost with real Date objects, written out field by field so a new column is a decision, not an accident. The database is honest about its storage format, and the domain model is honest about what it means.
db-result: the error lane narrows
kyselyTryDb is the wrapper that makes every terminal, execute, executeTakeFirst, executeTakeFirstOrThrow, resolve to Promise<Result<T, E>> instead of throwing. The question behind db-result was how far upstream that shape can go: can the query builder itself hand back Results, with the E union trimmed to what each query can actually produce? The answer was a wrapper that classifies driver errors by their shape. SQLite says UNIQUE constraint failed: t.c. D1 says D1_ERROR: … (code 2067 SQLITE_CONSTRAINT_UNIQUE[2067]). Connection failures carry system codes. Each becomes a tagged error: UniqueViolation, ForeignKeyViolation, NotNullViolation, CheckViolation, ConnectFailure, and friends. The original error stays attached as Error.cause for the logs; only the tag and a small payload reach your code.
And the lane narrows per query shape:
// a SELECT can never violate a constraint, and the type system knows it
Result<PostRow[], DataError | LockTimeoutError | ConnectFailure | ConnectionLost
| AuthorizationFailed | SqlSyntaxError | QueryFailure>
// an INSERT can, so the full protocol union is in scope
Result<PostRow, SqliteDbError | NoResultError>A read provably cannot produce a UniqueViolation, so a read handler never has to think about one; the union just does not contain it. A write can, so the write handler's fold sees it. The compiler refuses to let you fold an error that cannot happen, and refuses to ignore one that can.
The crown jewel: the insert is the check
Creating a post needs a unique slug. The classic approach is a pre-flight check:
const existing = await db.selectFrom("post").where("slug", "=", input.slug).executeTakeFirst();
if (existing) return err(errors.slugTaken({ slug: input.slug }));
// then insert, and pray nobody else grabbed the slug in betweenThat check-then-insert window is a race, the kind that only shows up when two people publish at the same time. The correct answer was always to skip the check and let the database enforce the constraint. The problem was turning the driver's error into something your API can say.
With db-result, the insert is the check, and the constraint error is a value you fold:
return (
await db
.insertInto("post")
.values({ slug: input.slug, title: input.title, markdown: input.markdown, /* … */ })
.returningAll()
.executeTakeFirstOrThrow()
)
.tryRecover((e) => {
if (UniqueViolation.is(e)) {
return err(errors.slugTaken({ slug: input.slug }));
}
if (ForeignKeyViolation.is(e)) {
return err(errors.notFound({ slug: input.categorySlug ?? "" }));
}
throw e; // scenario C: the rest falls through the cracks
})
.map(decodePost);Two folds, one insert. A duplicate slug becomes post/slug-taken, a 409 with the slug in the payload. A category deleted between the form load and the submit becomes post/not-found, because the foreign key is the database's way of saying that category does not exist, and it cannot go stale. Everything else, a genuinely broken query, a connection failure, is rethrown to become the sanitized internal error.
No pre-flight SELECT. No window. The uniqueness check and the write are the same statement, and the database's answer is the API's answer, mapped onto a tag the client was told about at compile time.
The same fold, in miniature, powers the category and note creators:
const constraintTo = <E extends AnyTaggedError>(toDeclared: () => E) => (e: unknown) => {
if (isConstraintViolation(e)) return err(toDeclared());
throw e;
};Optimistic concurrency, same shape
The revision guard on updatePost works the same way. Every post carries a revision integer. A writer sends the revision it started from, and the UPDATE re-checks it in the WHERE clause:
const updated = (
await db
.updateTable("post")
.set({ ...patch, modified_at: epoch(new Date()), revision: existing.revision + 1 })
.where("slug", "=", input.slug)
.where("revision", "=", input.expectedRevision)
.returningAll()
.executeTakeFirst()
).unwrap();
if (updated === undefined) {
const current = (
await db
.selectFrom("post")
.selectAll()
.where("slug", "=", input.slug)
.executeTakeFirst()
).unwrap();
if (!current) return err(errors.notFound({ slug: input.slug }));
return err(
errors.staleRevision({
slug: input.slug,
expected: input.expectedRevision,
current: current.revision,
}),
);
}undefined means the WHERE clause rejected the write: you lost the race. The check and the write are one statement again, and because the error carries a payload, the CLI can say something a status code never could:
Post changed while the update was in progress (revision 5, you had 3).
Fetch it again and reapply your changes.Errors ride the wire
The tags survive the trip. result-rpc declares them once, with a codec and an HTTP status:
export const postErrors = defineErrors("post", {
notFound: { data: wire.object({ slug: wire.string }), httpStatus: 404 },
slugTaken: { data: wire.object({ slug: wire.string }), httpStatus: 409 },
staleRevision: {
data: wire.object({ slug: wire.string, expected: wire.number, current: wire.number }),
httpStatus: 412,
},
});The browser client branches on the tag with postErrors.slugTaken.is(error) and reads the payload. The dashboard says a post already exists at that slug. The comment composer says GitHub is not responding, because the fetch failure was folded to comment/author-unavailable at the procedure boundary, marked retry: "transient".
The CLI is the most complete consumer. cli/failures.ts holds the exhaustive projection from tag to English. Every tag in the contracts, including the framework's own server/internal and client/offline, must have a handler or the build breaks:
errorCatalog(failures, {
"post/slug-taken": (error) => `A post already exists at "${error.data.slug}".`,
"post/stale-revision": (error) => [
`Post changed while the update was in progress (revision ${error.data.current}, you had ${error.data.expected}).`,
"Fetch it again and reapply your changes.",
].join("\n"),
"server/internal": (error) =>
`Server error. Incident ${error.data.incidentId} — grep the Worker log for it.`,
// …every other tag, all compile-time checked
});Adding an error to a contract breaks the CLI build until you have written the sentence a human should read.
The three libraries
I wrote this post with the blog CLI, which calls the same procedures the dashboard calls: blog update --publish and a click on the dashboard switch are the same code path.
bun run blog:prod update every-database-error-is-a-value \
--body-file - --publish < post.mdThe CLI is just another client of the same Result-returning procedures, with its own fold at the very edge: one function unwraps the Result or prints the described failure and exits. Everything upstream carried the failure as a value with its payload intact, which is why the message can say "revision 5, you had 3" instead of "412".
The three libraries are one ecosystem: same tags, same payloads, same folding, from the driver to the CLI. The blog's move off Drizzle is a story for another post: the Drizzle schema kept as the source of truth, the Kysely types generated from it. This one was about the question that produced db-result: how far upstream an honest Result<T, E> shape can go, with the E union trimmed to what a query can actually produce. The pattern I keep coming back to is the one this post was about: skip the pre-flight check, let the database be the authority on why a write failed, and fold its answer onto your own error vocabulary.