Errors & Exceptions
By the end of this lesson you will be able to tell a programmer's bug (Error) apart from an expected failure (Exception), write correct try/catch/finally code, trace EXACTLY how an uncaught error unwinds the call stack floor by floor until something catches it, read a real Dart stack trace, design your own exception classes, and choose between throwing and returning a typed result.
1. What goes wrong: Error vs Exception
catch block). If NOBODY on any floor handles it, the alarm reaches the roof and the whole building shuts down (the program crashes). Dart calls this alarm-and-escalation object a thrown object, and the ringing-upward process stack unwinding — the subject of section 3.Dart splits "things that go wrong" into two families, and the difference matters for how you should react to them:
Error— a signal that your code has a bug. The fix is to change the code, not to catch and paper over it. Examples: indexing past the end of a list, calling a method that doesn't exist, running out of stack space.Exception— a signal that something expected but undesirable happened while the code itself was correct: a file wasn't there, user input was garbage, a network call took too long. These are worth catching and recovering from.
Both Error and Exception are just classes — and in Dart, anything can be thrown, not only things that extend Error or implement Exception. throw 'oops'; and throw 42; both compile and run. The one rule enforced at compile time is that the thrown expression's type must be assignable to Object — so throw null; is rejected before your program even runs: "The type 'Null' of the thrown expression must be assignable to 'Object'." Being allowed to throw a bare string doesn't make it a good idea (see the pitfall below).
In Dart: any non-null object can be thrown and caught; throw null; is kept as a comment because it does not compile.
throw 'file not found'; compiles doesn't mean you should do it. A bare string or number gives a catch block nothing to inspect except its exact text — no type to match on, no fields to read, no stable identity across refactors. Always throw an object that extends Error or implements Exception (built-in or your own), verified in verify/d12.dart as the dispatch example's fallback case.Here are the Error and Exception types you'll meet constantly, with their exact messages verified by running each one in verify/d12.dart and capturing the real output — not guessed from memory:
| Family | Type | Fires when… | Exact message (Dart 3.11, verified) |
|---|---|---|---|
| Error a bug — fix the code |
RangeError | list[5] on a 3-element list | RangeError (length): Invalid value: Not in inclusive range 0..2: 5 |
TypeError | assigning a String into an int variable via dynamic | type 'String' is not a subtype of type 'int' | |
StateError | [].first on an empty list (or any object used while in the wrong internal state) | Bad state: No element | |
ArgumentError | a caller passes a value a function explicitly rejects, e.g. ArgumentError.checkNotNull(null, 'x') | Invalid argument(s) (x): Must not be null | |
UnsupportedError | calling .add() on a List.unmodifiable(...) | Unsupported operation: Cannot add to an unmodifiable list | |
NoSuchMethodError | calling a method that doesn't exist on the receiver's actual type (often via dynamic) | NoSuchMethodError: Class 'int' has no instance method 'foo'. | |
StackOverflowError | recursion (or any call chain) that never bottoms out exhausts the stack | Stack Overflow (this is the WHOLE toString() — no extra detail) | |
| Exception expected failure — catch and recover |
FormatException | parsing malformed input, e.g. int.parse('abc') | FormatException: Invalid radix-10 number (at character 1) (plus the offending text and a ^ marker on following lines) |
IOException | an interface (not thrown directly) implemented by concrete dart:io failures like PathNotFoundException, SocketException, FileSystemException | varies by OS and failure, e.g. PathNotFoundException: Cannot open file, path = '...' (OS Error: No such file or directory, errno = 2) — the exact wording is OS-dependent, but the TYPE and the fact that it implements IOException are stable, verified in verify/d12.dart | |
TimeoutException | an operation (commonly Future.timeout) exceeds an allotted duration | TimeoutException after 0:00:01.000000: timed out (message text you supply, plus the duration) |
In Dart: the table above as running code. Each risky call is tried, classified with is Error / is Exception, and its exact message printed (the // => lines are checked against a real run).
Scale: these checks are one is test each, so classifying even 106 caught objects is trivial; the expensive part is throwing (capturing a stack trace), so never throw as ordinary control flow in a hot loop.
Error and Exception are both just abstract Dart classes/interfaces — the language does not treat them specially at the type-system level; you can catch either family, or both, exactly the same way with catch. The naming split is a convention the whole Dart ecosystem follows so that "should I fix my code or should I handle this gracefully?" has a fast, consistent answer just from the exception's name.Error = programmer bug, fix the source. Exception = expected failure, catch and recover. Dart lets you throw literally any non-null object, but only throwing proper Error/Exception types gives callers something structured to catch.2. throw, try/catch/finally, rethrow
try block is a trial run with a safety net: you attempt something risky, and if it goes wrong midway, control jumps straight to the catch block instead of continuing line by line. The finally block is the closing checklist a pilot runs through no matter how the flight went — safe landing or emergency landing, the checklist (turn off engines, stow the tray tables) always runs.In Dart: the same try/catch/finally as the animation, run twice: once failing, once not. finally appears in both logs.
catch (e) alone catches anything thrown, regardless of type. on SomeType catch (e) only matches if the thrown object is a SomeType. You can chain several on clauses, and Dart tries them top to bottom, running the first one that matches — so put the most specific types first and a bare catch (e) last, as a safety net.
In Dart: the first matching on clause wins; a bare catch is the last-resort safety net.
catch (e) { ... } before a more specific on FormatException catch (e) { ... }, the specific clause becomes dead code — the generic one always fires first and nothing ever reaches the specific handler. The Dart analyzer will even warn you about this ("dead code").A finally block runs no matter what — whether the try completed normally, returned early, or an exception was thrown and caught (or even left uncaught, on its way past). But there's a sharp edge: if a finally block itself contains a return, that return silently overrides whatever the try block already decided to return, and even swallows any exception that was in flight.
In Dart: three functions: a normal try-return, a finally that overrides the return value, and a finally that swallows an in-flight exception.
return (or a break/continue out of a loop, or another throw) inside finally is a real, verified Dart behavior — not a hypothetical gotcha. verify/d12.dart proves fromFinally() returns 2, discarding the try block's return 1 entirely — and that a return in finally will just as silently swallow an actual in-flight EXCEPTION, not merely a pending return value: swallowsError() throws StateError('boom') in its try but returns 99 with no exception ever reaching the caller. Never put a return inside finally unless you deliberately want to override the result (which is almost never what you want).When you catch an exception but can't fully handle it, you can pass it back up with rethrow;. This is different from writing throw e;, and the difference is about the stack trace, not the exception object itself:
In Dart: rethrow; versus throw e;, comparing the top (#0) frame of the stack trace each one leaves behind.
rethrow re-throws the SAME exception object with its ORIGINAL stack trace still attached — the trace still points at where the problem first happened. throw e; throws that same object again, but as a brand-new throw at this line, so Dart builds a fresh stack trace starting HERE, permanently losing the information about where it originally went wrong. verify/d12.dart checks this directly: after rethrow, the top frame of the caught stack trace is still the function that first threw (origin in the panel above, level3 in the stack-trace panel in section 4); after throw e, the top frame has moved to the re-throwing function (viaThrowE).on Type catch clauses are tried top-to-bottom; put specific types first, generic catch (e) last. A return inside finally overrides everything before it. Prefer rethrow over throw e whenever you're re-throwing the exception you just caught — it preserves the original stack trace for debugging.3. Stack unwinding, animated
c()) catches fire and rings the alarm. Floor 12 has no fire marshal (no matching catch), so before evacuating it runs its own closing checklist (finally) and the alarm keeps ringing UP to floor 11 (b()). Floor 11 also has no fire marshal — it runs its checklist too and passes the alarm further up, to floor 10 (a()). Floor 10 finally has a fire marshal (a matching catch) who handles it, then floor 10 runs its own checklist. The alarm never reaches any floor above 10 — it stopped exactly where a handler existed.This is exactly what Dart does when an exception is thrown and the function that threw it has no matching handler: the current stack frame is popped (after running its finally, if any), and the search for a matching catch continues in the caller's frame, and the caller's caller, and so on — this is stack unwinding.
In Dart: the page's a()/b()/c() chain; each finally appends to a log so the order is printed.
finally block it passes through, innermost frame first, before the matching catch runs. If NO frame all the way up to main() has a matching handler, the program terminates and prints an "Unhandled exception" stack trace.4. Reading a stack trace
A catch (e, st) clause's second parameter, st, is a StackTrace — a snapshot of exactly which functions were active, in order, at the moment the exception was thrown. Printing it produces lines like this (captured for real from level2Rethrow() in verify/d12.dart, shown here with the file paths shortened; the number of frames and the wording of the last, Dart-internal ones differ between Dart versions and between dart run, compiled and Flutter programs):
#0 level3 (d12.dart:LINE) #1 level2Rethrow (d12.dart:LINE) #2 main (d12.dart:LINE) #3 _delayEntrypointInvocation.<anonymous closure> (dart:isolate-patch/isolate_patch.dart:…)
In Dart: capture a real StackTrace in a catch (e, st) clause and print only #N function for the first two frames (file paths and line numbers differ per machine, so they are trimmed).
Read it top to bottom: #0 is where the exception was thrown (the innermost frame — here, inside level3). Each following line is that function's caller, in order, all the way out to where the program started. The bottom few frames (mentioning dart:isolate-patch) are Dart's own startup machinery — you can ignore those; your own function names near the top are where to start looking.
#0 is the exact throw site; each higher number is one caller further out. When debugging, start reading at #0 and stop once you reach code you recognize as the actual mistake — everything below that is just "how we got there".5. Custom exception classes
throw StateError('withdrawal failed') is like a fire alarm that only ever says "something happened somewhere" — useless for deciding what to do next. A custom exception class is a labeled, detailed incident report: it carries its own type (so a catch can select it specifically) plus structured fields (so the handler can read exactly what happened) instead of just a sentence.Create a custom exception class when: (1) callers need to react differently depending on what went wrong, (2) the failure carries data a plain string can't hold cleanly (amounts, IDs, limits), or (3) you want a stable, greppable type name across a whole codebase. Implement Exception (a marker interface with no required members) for expected failures your API deliberately raises:
In Dart: an abstract base implementing Exception, two concrete subclasses with their own fields, and callers that catch narrowly (on OutOfStock) or broadly (on PaymentException).
abstract base class implementing Exception, with several concrete subclasses — lets callers catch broadly (on BankException catch (e) to handle "any banking problem") OR narrowly (on InsufficientFundsException catch (e) to handle just that one case specially, perhaps by offering an overdraft), using the exact same on Type catch ordering rule from section 2.catch to match on) plus data (fields the handler can read) plus a clear toString() (for logs). Group related failures under one abstract base class so callers can choose how specific to be when catching.6. assert & defensive programming
assert is a smoke detector installed only in the workshop, not in the finished product shipped to customers. While you're building and testing, it screams the instant something impossible happens, so you catch the bug right there. Once the product ships (a release build), the smoke detectors are removed entirely — they cost nothing in production, but they also give you zero protection there, which is exactly why you still validate real inputs separately with actual if checks and thrown exceptions.assert(condition, 'message') throws an AssertionError if condition is false — but ONLY when assertions are enabled. Assertions are ON by default in flutter run (debug mode) and when you pass --enable-asserts to the Dart VM, and they are COMPLETELY STRIPPED OUT (skipped, at zero cost) in release builds and by plain dart run. This is verified directly in verify/d12.dart: an assert(1 == 2, ...) does NOT throw under plain dart run, because that command runs without assertions enabled.
In Dart: the classic trick assert(assertsOn = true) detects whether assertions are on. Under plain dart run they are off, so the false assert is skipped; the last line shows what an AssertionError looks like when assertions are enabled (dart --enable-asserts or flutter run debug).
assert to validate anything that depends on real-world input (user text, file contents, network responses) — in release builds it silently does nothing, so a bad input sails straight through. Use assert only to catch YOUR OWN logic mistakes during development (e.g. "this list should never be empty here if my code is correct"). For anything that can go wrong because of the outside world, throw a real exception unconditionally.For real validation, Dart's SDK gives you ready-made, fail-fast helpers that build a correctly-worded ArgumentError or RangeError for you:
In Dart: ArgumentError.checkNotNull, ArgumentError.value and RangeError.checkValidIndex with their exact messages.
assert is a free, debug-only internal consistency check — it vanishes in release builds, so never use it for real input validation. ArgumentError.checkNotNull and RangeError.checkValidIndex give you the same fail-fast checks with correct, standard messages, and they run in EVERY build.7. Errors in async code — a preview
Everything you've learned so far still applies inside an async function: wrapping an await someFuture; in a normal try/catch catches whatever error that Future completes with, exactly like a synchronous throw:
In Dart: a Future that fails with nobody awaiting it. The try block finishes first, so the sync catch never runs; the error surfaces later in the zone's uncaught-error handler (runZonedGuarded lets us observe it here). Full event-loop mechanics: D14.
Future whose error nobody ever awaits or attaches a .catchError to — that becomes an uncaught async error, which crashes an isolate (or, in Flutter, gets reported to FlutterError.onError) completely independently of any surrounding synchronous try/catch, because that try/catch already finished running before the Future even completed. A full treatment of the event loop, microtasks, and exactly when this happens is coming in D14 · Async & the Event Loop — for now, the one rule to hold onto is: always await (inside a try) or attach an error handler to every Future you create.try/catch around await works exactly like synchronous error handling. A Future's error that nothing ever observes becomes an uncaught async error — full mechanics in D14.8. Alternatives to throwing, and best practices
try/catch is like installing a fire alarm on a light switch. For routine, expected outcomes, it's often clearer to just return the answer, including the "no" answer, as a normal value.Two common alternatives to throwing:
- Return a nullable value — e.g.
double? safeDivide(num a, num b)returnsnullinstead of throwing whenb == 0. Simple, but a plainnullcarries no explanation of WHY it failed. - Return a sealed
Resulttype — a small class hierarchy with anOk(value)case and anErr(message)case (using Dart 3'ssealedclasses, covered fully in D11). The caller is FORCED by the type system to handle both cases (aswitchover a sealed type must be exhaustive), and the failure case can carry as much detail as you want.
In Dart: a nullable return and a sealed result; the switch must cover both cases or the code does not compile.
Result for outcomes that are a normal, frequent part of the function's job (a form field failed validation, a lookup found nothing). Parsing user input is a classic case for Result/nullable returns — it's completely normal for user input to be wrong.A short checklist for error-handling hygiene, all demonstrated (and verified) somewhere in this lesson:
- Catch specific types, not a bare
catch (e), whenever you can react differently to different failures. - Never swallow an error silently — an empty
catch (e) {}hides real bugs. At minimum, log it; ideally, still surface SOMETHING to the caller (a visible fallback, not silence). - Don't catch
Error(or its subtypes likeRangeError) as if it were an expected condition — that hides a bug instead of fixing it. Validate BEFORE the risky operation instead (section 6). - Fail fast — validate arguments at the top of a function rather than letting a bad value travel deep into the system before something finally breaks confusingly far from the real cause.
- Use
finallyfor cleanup (closing files, releasing locks, rolling back partial work) — it is the one block guaranteed to run whether the risky code succeeded, failed, or even returned early.
In Dart: specific catches versus swallow-everything: the broad catch turns a real bug into a quiet -1, the specific one lets the bug surface.
StackOverflowError is a special trap: by the time it's thrown, the call stack is already exhausted. Your catch block itself needs a little bit of stack space to run — and depending on exactly how much headroom is left, even simple recovery code inside that catch can immediately trigger ANOTHER overflow. Treat StackOverflowError as a signal to fix the recursion (add or fix the base case, or convert to an explicit loop), not as something to gracefully handle at runtime.In Dart: catching StackOverflowError (we only observe that it happened, never rely on it) and the dependable fix, an explicit heap stack that visits 106 levels without recursion.
Result values for routine, expected outcomes. Always catch specific types, never swallow silently, never catch Error types defensively, and use finally for guaranteed cleanup.Quiz
Interview questions
Cheat sheet
| Concept | Syntax / rule |
|---|---|
| Error vs Exception | Error = programmer bug, fix the code. Exception = expected failure, catch and recover. |
| Throwing | throw expr; — expr can be any non-null Object; throw null; is a compile-time error |
| Catch anything | catch (e) or catch (e, st) — matches any thrown object |
| Catch a specific type | on SomeType catch (e) — tried top-to-bottom, first match wins; put specific types before generic catch |
finally | always runs — success, caught exception, or uncaught exception passing through. A return inside it OVERRIDES the try block's return. |
rethrow vs throw e | rethrow; keeps the ORIGINAL stack trace; throw e; builds a NEW one starting at that line |
| Stack unwinding | An uncaught throw pops frames outward (running each finally along the way) until a matching catch is found or the program terminates |
| Stack trace frames | #0 = throw site (innermost); each higher number is one caller further out |
| Custom exception | class MyError implements Exception { final String message; ... } — type + fields + toString() |
assert(cond, msg) | Throws AssertionError only when assertions are ENABLED (debug/--enable-asserts); stripped entirely in release / plain dart run. Never for real input validation. |
| Fail-fast helpers | ArgumentError.checkNotNull(v, 'name'), RangeError.checkValidIndex(i, list) — always active, correct standard messages |
| Async errors | try { await f(); } catch (e) { ... } catches a Future's error like a sync throw; an unobserved Future error becomes an uncaught async error (full detail in D14) |
| Alternatives to throwing | Return T? (nullable) or a sealed Result<T> (Ok/Err) for routine, expected outcomes |
| Best practices | Catch specific types; never swallow silently; never catch Error defensively; validate early (fail fast); clean up in finally |