Somebody reports that your API returned a 500. You look it up, and the response body says: Bind a D1 database named DB in wrangler.jsonc.
That message is perfect. It is also now in a stranger’s browser, describing your deployment to them.
A backend failure has at least two audiences and they want opposite things. Whoever called your API wants to know what to do differently. Whoever runs it wants to know which binding is missing, which migration did not apply, which provider console to open. Give the caller the operator’s answer and you have published your infrastructure. Give the operator the caller’s answer and they are debugging blind.
Most codebases handle this at the throw site — remember to put the sensitive part somewhere else, remember to keep it out of the response. That works until the fiftieth throw site.
One vehicle, one schema
Pithy has exactly one thing that gets thrown. PithyError is the only throw/catch vehicle in the kit, and one Zod schema is the whole definition of every failure it can carry.
The class does not extend that schema — it carries one. The reason is unglamorous and decisive. A thrown thing has to be instanceof Error so catch works, so a stack trace exists, so cause chains. A Zod object is a data shape, not an Error. So the class holds a payload and validates it in the constructor:
constructor(payload: ErrorPayload, options?: { cause?: unknown }) {
const parsed = ErrorPayload.parse(payload);
super(parsed.message, options);
this.payload = parsed;
}Because that parse happens at construction, every PithyError in flight is a member of a known taxonomy. There is no path where a half-formed error object exists and gets discovered three frames later.
The taxonomy is a discriminated union keyed on a machine-readable code, and it is closed — a new capability’s codes are added to it, not invented at the call site. With one deliberate exception: a single open member, so you can raise errors under your own domain without your codes having to belong to us. The instruction that comes with it is short and worth heeding. Never switch exhaustively over the open union. It is open. Your default branch is not a formality.
The subclasses are sugar. Each defaults a code and a status that the union already defines, so they add convenience without adding a second source of truth.
Two audiences, one place to decide
Here is the part that makes the whole arrangement earn itself.
The payload has public fields and operator-only fields. message is public — its entire definition is “safe to expose”. action and detail are not, and the reasoning for action is worth quoting in full because it is easy to get wrong:
actionis a remedy for somebody with the project checked out. Read the ones the kit ships and that is unmistakable: they namepithycommands, files in the adopter’s repository, wrangler bindings, D1 databases, provider consoles. Sent to a browser, that is a description of the deployment.
Which is the message you started this post with. It is exactly what you need when you are on call, and exactly what an attacker would like handed to them. So both fields travel only on the surfaces an operator reads: the terminal, the CLI’s --json output, a log, an audit row.
And if the caller genuinely needs telling what to do? That is not a third audience. That is message.
Why the throw site is not asked to remember
The obvious implementation is a rule: don’t put secrets in the response. The rule Pithy uses instead is structural, and the source comment says why plainly:
A throw site is where this codebase has repeatedly fixed one instance and left its siblings; there is nothing here for a throw site to get right, because the wire shape has no such key to fill.
The public schema does not have an action field. Not “has one that gets emptied” — does not have one. The HTTP codec parses the payload into the public shape, and the operator-only fields have nowhere to land. A throw site cannot leak detail to a browser by forgetting, because forgetting is not one of the available outcomes.
That leaves the surfaces as pure encoders over one schema. HTTP encodes the public subset. The terminal encodes the public subset plus action, because a terminal is an operator surface. The logger takes the whole payload, detail included — which reads as an inversion until you notice it is the same rule applied consistently. A log lives on the operator’s side of the boundary, so it gets what operators get.
One payload, four encoders
The reason a single shape is worth the trouble is that a failure has to arrive on four surfaces that normally have nothing to do with each other, and every one of them is served by the same object.
flowchart LR
T["One throw site"] --> O["One error object,<br/>carrying its own status"]
O --> H["HTTP response"]
O --> C["Terminal"]
O --> J["CLI --json"]
O --> L["Log and audit row"]
| surface | what it gets | reader |
|---|---|---|
| HTTP response | public fields, at the status the code declares | the caller |
| Terminal | message as the problem line, action as the action line | an operator at a keyboard |
CLI --json | the wire fields, plus action | the same operator, scripted |
| Log and audit row | everything, detail included | an operator, afterwards |
The status is the part people expect to be wired up by hand, and it is not. Each member of the union declares its own:
status: z.literal(400).describe("Bad Request."),
status: z.literal(401).describe("Unauthorized."),
status: z.literal(403).describe("Forbidden."),So the code and the HTTP status are one fact, not two that have to agree. There is no mapping table to update when a code is added, no switch translating names into numbers, and no way to throw a not_found that answers 200 — the union will not parse.
The terminal encoder is three lines, and that is the whole of it: message on the first line, action on the second if there is one. That is the two-line shape the CLI’s own voice guide specifies, so the CLI does not need error rendering of its own — it catches a PithyError, colors the first line, and prints this.
detail disappears from both, and it disappears the same way: the public parse. Not a second rule in the JSON encoder that could drift from the rule on the wire — one schema, applied twice.
What it looks like in practice
A webhook arrives with a signature that does not verify. The caller is told the request failed. detail records why it failed — which is deliberately not shared, because a forger who learns which check rejected them has been handed a free iteration of their attack, while an operator reading the same event needs precisely that.
One failure, one payload, two readers, and no decision required at the point where the error is raised.
That last part is the whole design. Any rule of the form “remember not to include X” gets broken in proportion to how many places have the opportunity. Removing the opportunity is not a more disciplined version of remembering. It is a different category of thing, and it is the only one that stays true while your codebase grows.