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 }) }).withPlugin(
plainDatePlugin(["public_at"]),
);
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.
The byline is a Temporal.PlainDate
Storage-faithful has one deliberate exception: the publish date. The date under this post's title is stored in D1 as the text 2026-08-11, in a column called public_at with a GLOB check that only admits YYYY-MM-DD. Read back, it is a Temporal.PlainDate, and the wire carries it as one. The string is the column's business; the rest of the system never sees it.
The marshalling is the plugin in the rawDb line above. On every read it turns the column into Temporal.PlainDate; on every write it folds instances back into their ISO string. The generated row type describes the query boundary, not the disk: public_at: Temporal.PlainDate | null. This column earns the exception because the marshalling is unconditional: the plugin sits on the only query builder the blog has, so no query path returns the raw string. A plugin-less path would be a compile-time error, not a silent one.
The column is also a known Panic. The GLOB check owns the format, and only the format: 2026-13-00 passes the check and is not a date. Temporal.PlainDate.from throws on it, and the plugin calls that eagerly, at the query boundary, so the failure becomes a sanitized server/internal with an incident id instead of a date-shaped string that leaks into the sitemap. The database owns the format, Temporal owns the calendar, and the panic is the fall-through between the two.
And the wire does not flatten it. result-rpc 0.5.0 carries Temporal.PlainDate as a first-class codec, wire.plainDate, so the receiving side gets the instance instead of a string to re-parse with a date library. I keep a script that proves it end to end: create a scratch draft, publish it, read it back through the wire, assert the type, delete it. It prints:
instanceof Temporal.PlainDate: true
value: 2026-08-12
OK — Temporal.PlainDate came all the way across the wire (2026-08-12).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. The answer is all the way up. Both ends of the wire run TypeScript, and that is the itch: once the database's constraint error is a tagged value with a payload, the only place left to lose it is the wire, and a wire that JSON-serializes flattens it into status codes and strings. So the wire carries the union. post/slug-taken arrives as the same tag the handler folded, a Panic is a server/internal with an incident id, and wire.plainDate delivers a Temporal.PlainDate, not a string to re-parse. The wire format is devalue, which does not flatten: Date crosses as Date, a Temporal.PlainDate as a Temporal.PlainDate.
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. It is a good pattern, and it is not the whole thing. The wire is the part that usually breaks: most RPC layers turn errors into status codes and strings at the boundary, and the client re-derives meaning from them.
I know the reply this post gets: things people do, just to not write Effect. The line is Sameer's, under adam's "just use effect" tweet, and it is aimed at Result-pilled posts like this one. Fair cop. Effect is a runtime that would own the whole program; this blog is a Worker with an RPC layer and a CLI, and the itch was narrower. The wire was the part of the chain I thought could not be fixed with a Result type. It could. Both ends run TypeScript, so both ends speak the same union.
Should I just use Effect?
That reply is not wrong anymore. Effect v4 is in beta, and it is the first version where "things people do, just to not write Effect" has teeth: the fiber runtime was rewritten, a minimal program using Effect, Stream, and Schema dropped from about 70 kB to 20 kB, every package versions together, and HttpApi gives you typed endpoints, error schemas with status codes, and a client that shares the contract. Most of what this post is about is bundled in there.
I checked, because checking was cheap. The beta launched in February 2026, and the team's own line is that v3 is still the production recommendation. The beta is moving fast: Context was renamed to ServiceMap and back. That is fine for a library and bad for a blog I deploy to production whenever I push.
The bigger reason is that Effect is a runtime, not a library. better-result is a value I can sprinkle on a handler. Effect is a decision about the whole program: handlers become Effect programs, dependencies flow through Layer and Context, everything runs in the fiber runtime. It would own the blog, and the blog is a Worker with an RPC layer and a CLI.
And the two things this post took effort to build are not in the bundle. db-result's trick is that the error lane narrows per query shape: a SELECT provably cannot produce a UniqueViolation, and the type system knows it. Effect gives you typed errors and a generic SQL error type; the narrowing is still mine to write. result-rpc's wire is devalue, which serializes the graph with no schema at all; Effect's wire is Schema, which is exact and typed, and you write a schema for anything devalue would carry for free.
So the meme buys the plumbing and not the insight, and the plumbing was never the hard part.