Skip to content

Build1 publisher3 min readPublished

A guard wrapper rethrows every Flutter error with the stack it came from

A dev.to post sets out the Flutter error handling one client monorepo starts from: a sealed AppException vocabulary, one translation point per layer, and a rethrow that keeps the original stack so real bugs still reach Sentry.

The Engineer · Build desk

Illustration accompanying A guard wrapper rethrows every Flutter error with the stack it came from

What happened

  • A dev.to post sets one rule for Flutter error handling: catch only the failures you can act on, and let everything else reach the crash tool untouched.
  • The author describes a month of a zero crash dashboard while users hit errors the app caught and answered with a calm 'Something went wrong'.
  • handleException opens by returning any error that is already an AppException, so a failure is classified once and later layers do not overwrite the classification.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • cost Whoever owns support pays for a swallowed error twice: once in tickets nobody can reproduce, and again in a quarter planned against counts that never happened.
  • constraint Sealing the hierarchy moves enforcement into the build, so a team cannot add a failure type and ship it without writing the user-facing copy first.
  • decision Each existing catch (e) now needs a verdict: name the specific failure it handles, or delete it and let the error travel to the crash reporter.
  • exposure The type chosen at the boundary decides both the message a user reads and which team the number lands on, so a misclassification is visible in two places at once.

The important line is `Error.throwWithStackTrace(handleException(error, st), st)`. `guard` catches everything a repository call throws, including a null dereference, then maps it and rethrows it carrying the stack trace it arrived with [12]. So the rule against catching bugs is narrower than it sounds: guard catches them and rethrows them. The AppException it throws keeps the original error in `cause` and the original trace in `stackTrace`, so the crash report opens on the line the error came from and not on the handler that caught it [9].

`handleException` begins with `if (error is AppException) return error;`, so anything already classified passes through untouched, because re-classifying it throws information away [13]. FormatException maps to ServerException, and the author shipped it as ValidationException first [14]. The reason is now written into the code: "A FormatException reaching here came from decoding a payload, never from user input: input is validated before the call, and a rejected field arrives as a ValidationException from ApiClient's 400/422 branch. So this is a broken client/server contract." [15] While that line was wrong, the app told users to check input they could not change, and the team built a chart saying users were typing things wrong when a backend field had quietly changed type [16].

That mapping only transfers to your app if both premises in the comment hold [15]. Validate before the call, keep a 400/422 branch in the client, and a decode failure really is a contract break. If user input reaches the endpoint unvalidated, the same rule points at your backend for something a form field should have caught.

The adoption cost is concrete. Every repository extends BaseRepository and wraps its calls in guard [12]. Nine failure subtypes are named, so the switch that turns an exception into a sentence needs nine branches today, and adding a tenth breaks the build until the copy exists [1][11]. Six places touch an error on the way through, each with one job [17]. ApiClient maps status codes only, since no connection means no status [18]. On doing it twice, the post is blunt: map status codes in two places and six months later the two lists disagree [18].

The evidence here is one practitioner's account of their own projects. The forty catch blocks, most of them `catch (e)`, the month of a zero dashboard, and the contract that had been broken for nine days come from the post, which does not report a before-and-after error count [6][5][7]. The author says they have been writing Flutter since 2018 and web since 2002, and the code is public in bp-bloc, bp-riverpod and bp-mvvm [4][3]. "The worst bugs I have shipped were not the ones that crashed," the author wrote. "They were the ones nobody told me about." [20]

I would adopt the sealed switch before the rest of it. A catch block written to keep a red screen out of a demo outlives the demo [6], and with the switch sealed the build fails until the copy exists [11].

What to watch

  • Whether the public bp-bloc, bp-riverpod and bp-mvvm repositories contain the full six-stage error path and the handleException switch, not just the snippets quoted.
  • Any team publishing before-and-after error counts from removing bare catch (e) blocks; the post reports none.
  • Whether the ApiClient-maps-status-codes, guard-maps-transport split survives a second client or transport being added to the monorepo.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories