The errors were never the problem. The strings were.
Last week I wired Paraglide message keys through the whole Greenhouse frontend so the UI could speak Spanish and Portuguese someday. It went fine, mostly, once I found the one config key that silently drops every message you write. But partway through that work I hit a wall I’d been avoiding: every error message the app shows the user starts life as a hand-formatted English string in Rust.
Err(EngineError::Invalid(format!("{item_id} is not active")))
That string travels straight from a Rust format!() call to a role="alert" banner in the browser, unchanged. I could translate every button label and dialog heading in the app, and the one place users actually see something go wrong would still only ever speak English. Worse, some of these read like debug output, not user copy - “{item_id} is already at the last stage; use release_project” is a note to a developer, not a sentence for a person shelving a beat idea.
Fixing it meant admitting the string was never the real problem. The problem was that the error didn’t carry any meaning past the point where it was constructed. By the time it reached the frontend, all that existed was English text - no way to ask “what actually happened here” without regex-parsing prose.
The fix: send the shape of the problem, not a sentence about it
Tauri v2 lets a command return any error type that implements Serialize, not just a string. So the fix was to stop formatting sentences in Rust and start describing conditions instead:
#[derive(Serialize)]
#[serde(tag = "kind", content = "params", rename_all = "camelCase")]
pub enum ErrorKind {
ItemNotActive { item_id: String },
NameEmpty,
Internal { detail: String },
// ...twenty-some more
}
Over the wire that becomes {"kind": "itemNotActive", "params": {"itemId": "..."}}. The frontend gets a lookup table from kind to a Paraglide message and calls it a day. No parsing, no guessing, no English baked into the transport layer at all.
Not every error deserves this treatment, though. A SQLite corruption error or a failed OS directory lookup isn’t something a translated sentence helps with - nobody reads “the database disk image is malformed” in any language and knows what to do next. Those collapse into one Internal { detail } kind with a generic “something went wrong” message on the frontend, and the raw diagnostic stays in a dev-console log instead of the user’s face.
The gotcha that would’ve shipped silently broken
I wrote #[serde(rename_all = "camelCase")] on the enum and assumed it handled everything - variant names and the fields inside each variant. It doesn’t. rename_all only renames the tag (ItemNotActive → itemNotActive). The field inside stayed item_id, snake_case, sitting right next to a camelCase tag like nothing was wrong.
No compile error. No runtime error. Just a JSON payload that looked almost right. If I’d only checked “does this compile” or “does the test pass,” this ships, and every parameterized error message silently fails to read its own parameter on the frontend. The only way I caught it was writing an actual test that compared the serialized output against the JSON I expected:
assert_eq!(
serde_json::to_value(&err).unwrap(),
json!({ "kind": "itemNotActive", "params": { "itemId": "abc-123" } })
);
That test failed immediately, with the mismatch sitting right there in the diff. The fix was one more attribute, rename_all_fields, which does what I’d assumed rename_all already did.
Proving the wire actually agrees with the test
A unit test proves the Rust side serializes correctly. It doesn’t prove the real IPC bridge - the actual webview, actual JSON marshaling - delivers that same shape to a real browser tab. So I drove the running app with the WebDriver harness built for an earlier issue, called the low-level Tauri bridge directly against a deliberately bad item ID, and read back exactly what the browser received:
{"kind":"itemNotFound","params":{"itemId":"does-not-exist"}}
Then I fed that same object through the frontend’s translation function and watched it turn into “This item couldn’t be found. It may have been moved or removed.” Not [object Object], not a raw JSON string stuffed into a banner - the actual sentence a person should see.
That last check mattered more than I expected going in. It’s easy to prove each half of a pipeline works in isolation and never actually watch the two halves talk to each other.
Related reading
The config that got baked into every vault, forever
Stage names serialized into every config.yaml at creation time made translation and templates a per-vault migration - until the words stopped being data.
Two notes nobody ever saw
Greenhouse saved a capture note and a handoff note faithfully, and showed neither: one had no field in the wire type, the other had a working command nobody called.
The scan that offered to import itself
An import scanner that found the app's own scaffolding, then re-offered a folder it had just imported - two versions of the same missing conversation between layers.