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

Picture a tall office building with a fire alarm system. If a fire starts on floor 12 (some code goes wrong), the alarm doesn't just stay on floor 12 — it rings upward, floor by floor, until it reaches someone with the authority and the plan to deal with it (a 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:

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.

Just because 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:

FamilyTypeFires when…Exact message (Dart 3.11, verified)
Error
a bug — fix the code
RangeErrorlist[5] on a 3-element listRangeError (length): Invalid value: Not in inclusive range 0..2: 5
TypeErrorassigning a String into an int variable via dynamictype '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
ArgumentErrora caller passes a value a function explicitly rejects, e.g. ArgumentError.checkNotNull(null, 'x')Invalid argument(s) (x): Must not be null
UnsupportedErrorcalling .add() on a List.unmodifiable(...)Unsupported operation: Cannot add to an unmodifiable list
NoSuchMethodErrorcalling a method that doesn't exist on the receiver's actual type (often via dynamic)NoSuchMethodError: Class 'int' has no instance method 'foo'.
Receiver: 5
Tried calling: foo()
StackOverflowErrorrecursion (or any call chain) that never bottoms out exhausts the stackStack Overflow (this is the WHOLE toString() — no extra detail)
Exception
expected failure — catch and recover
FormatExceptionparsing 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)
IOExceptionan interface (not thrown directly) implemented by concrete dart:io failures like PathNotFoundException, SocketException, FileSystemExceptionvaries 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
TimeoutExceptionan operation (commonly Future.timeout) exceeds an allotted durationTimeoutException 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.

Under the hood, 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

A 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.

Order matters. If you write a generic 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.

A 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).
Multiple 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

Back to the fire alarm: floor 12 (function 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.

Unwinding runs every 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.

Stack trace frames read innermost-first. #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

A generic 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).

Building a small hierarchy — one 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.
A custom exception is a type (for 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).

Never use 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.

The dangerous case is a 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

Throwing an exception is like pulling a fire alarm: dramatic, hard to ignore, and it interrupts everything until handled. Sometimes that's exactly right (a genuinely exceptional failure). But sometimes "this specific input was invalid" is a completely ROUTINE outcome you expect constantly — like a form-validation error — and forcing every caller to wrap every call in 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:

In Dart: a nullable return and a sealed result; the switch must cover both cases or the code does not compile.

A useful rule of thumb: throw for conditions that are genuinely exceptional and usually indicate a bug or an unrecoverable environment problem (file system full, network down); return a nullable value or a 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:

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.

Catching 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.

Throw for genuinely exceptional, usually-a-bug situations. Return nullable/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

ConceptSyntax / rule
Error vs ExceptionError = programmer bug, fix the code. Exception = expected failure, catch and recover.
Throwingthrow expr; — expr can be any non-null Object; throw null; is a compile-time error
Catch anythingcatch (e) or catch (e, st) — matches any thrown object
Catch a specific typeon SomeType catch (e) — tried top-to-bottom, first match wins; put specific types before generic catch
finallyalways runs — success, caught exception, or uncaught exception passing through. A return inside it OVERRIDES the try block's return.
rethrow vs throw erethrow; keeps the ORIGINAL stack trace; throw e; builds a NEW one starting at that line
Stack unwindingAn 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 exceptionclass 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 helpersArgumentError.checkNotNull(v, 'name'), RangeError.checkValidIndex(i, list) — always active, correct standard messages
Async errorstry { 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 throwingReturn T? (nullable) or a sealed Result<T> (Ok/Err) for routine, expected outcomes
Best practicesCatch specific types; never swallow silently; never catch Error defensively; validate early (fail fast); clean up in finally