Dart (3.13) Module 10 – Errors and exceptions
Error handling design and input validation
Choose between exceptions, nullable returns and sealed results in Dart, check untrusted input in a safe order and keep internals out of messages.
What you will learn
- Choose between an exception, a nullable return and a sealed result for each kind of failure
- Validate untrusted input with a size limit, a type check and an allowlist, in that order
- Write error messages that tell a person what to fix while the details go to a log
- Map every failure of a command line program to an exit code at one boundary
Before you start
What you’ll learn · Before you start
What you will learn
- Choose between an exception, a nullable return and a sealed result for each kind of failure
- Validate untrusted input with a size limit, a type check and an allowlist, in that order
- Write error messages that tell a person what to fix while the details go to a log
- Map every failure of a command line program to an exit code at one boundary
Before you start
On this page
A signup form can fail in many ways: a username of one letter, a name of 1 MB pasted by a script, a disk that is full, a bug in the code that saves the account. Error handling design is the set of decisions about each one: does the code throw it or return it, what does the person at the keyboard read, and what does the log keep?
This lesson makes those decisions for a small Dart signup. Every program in it ran on Dart 3.13.5, and the first decision is the one every check faces: how to report that it failed.
One rule, three ways to report that it failed
A username must be 3 to 20 characters from a-z, 0-9 and the underscore. This program reports a broken rule in each of the three ways Dart offers, and calls each one on the same three inputs:
// One username rule, reported three ways: an exception, a nullable return and a sealed result type.
final _shape = RegExp(r'^[a-z0-9_]+$');
String? _problem(String s) {
if (s.length < 3 || s.length > 20) return 'must be 3 to 20 characters, not ${s.length}';
if (!_shape.hasMatch(s)) return 'may hold only a-z, 0-9 and _';
return null;
}
// 1. An exception: the caller must remember to catch it.
class UsernameException implements Exception {
UsernameException(this.reason);
final String reason;
}
String checkUsername(String s) {
final problem = _problem(s);
if (problem != null) throw UsernameException(problem);
return s;
}
// 2. A nullable return: the type forces a null check, but null says nothing about why.
String? usernameOrNull(String s) => _problem(s) == null ? s : null;
// 3. A sealed result: the type forces a switch, and the refusal carries its reason.
sealed class UsernameCheck {}
class Accepted extends UsernameCheck {
Accepted(this.name);
final String name;
}
class Refused extends UsernameCheck {
Refused(this.reason);
final String reason;
}
UsernameCheck usernameCheck(String s) {
final problem = _problem(s);
return problem == null ? Accepted(s) : Refused(problem);
}
void main() {
for (final input in ['asha_k', 'A', 'ravi.kumar']) {
print('"$input"');
try {
print(' exception: accepted ${checkUsername(input)}');
} on UsernameException catch (e) {
print(' exception: refused, ${e.reason}');
}
print(' nullable: ${usernameOrNull(input) ?? 'null, with no reason'}');
final shown = switch (usernameCheck(input)) {
Accepted(:final name) => 'accepted $name',
Refused(:final reason) => 'refused, $reason',
};
print(' result: $shown');
}
} Output
"asha_k" exception: accepted asha_k nullable: asha_k result: accepted asha_k "A" exception: refused, must be 3 to 20 characters, not 1 nullable: null, with no reason result: refused, must be 3 to 20 characters, not 1 "ravi.kumar" exception: refused, may hold only a-z, 0-9 and _ nullable: null, with no reason result: refused, may hold only a-z, 0-9 and _
Recorded with Dart SDK 3.13.5 (stable) on macos_arm64 on macOS 26 arm64. To run it yourself: dart run three_ways.dart
All three accepted asha_k and refused the other two. The exception carried the reason, but nothing in
checkUsername’s type told its caller to catch it. The nullable return forced a null check through its type
String?, yet A and ravi.kumar both came back as a bare null: the reason was lost. The
sealed class: A class marked sealed: it cannot be extended or implemented outside its own library, so the compiler knows all its direct subtypes and can check that a switch over them is exhaustive. More in “Validating JSON in Dart with patterns” UsernameCheck did both. Its switch has to name Accepted and Refused
before it compiles, and Refused holds the reason, the pattern that
validating JSON with patterns used for request bodies.
Checkpoint
Check: Three ways to report a failure
Choose by what the failure means to its caller
None of the three is always right. The choice follows from what the failure means: is it part of normal use, does the caller need the reason, and how far away is the code that can act on it? Answer those questions in the table:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way to report it | Callers must handle it | It says why | It crosses many calls | It suits a normal outcome |
|---|---|---|---|---|
| Throw an exception The caller writes: try and on UsernameException | Poor nothing in the type: a caller can forget the try and the program stops | Good UsernameException carries the reason the rule gave | Good travels up through every caller until a catch takes it | Poor meant for failures outside normal use, not for every typo |
| Return null The caller writes: ?? or an if that checks for null | Good String? makes every caller deal with null before using the value | Poor null only says no; three_ways.dart could not tell A from ravi.kumar | Mixed each layer must pass the null on or decide what it means | Good the lightest choice when absence is the only outcome |
| Return a sealed result The caller writes: a switch over Accepted and Refused | Good an exhaustive switch does not compile without a Refused case | Good Refused holds the reason, and more fields if the caller needs them | Mixed each layer returns it on, which makes the failure visible in every signature | Good made for outcomes that are part of normal use |
How the answers decide
- “Is this failure part of normal use, such as a typo in a form, rather than a full disk or a lost connection?” No, it means something broke: rules out Return null and Return a sealed result. A failure outside normal use should not sit in every return type; throw an exception and catch it where something can be done.
- “Is this failure part of normal use, such as a typo in a form, rather than a full disk or a lost connection?” Yes, it happens every day: rules out Throw an exception. An everyday outcome is not exceptional; a caller that forgets a try would stop the program on a typo.
- “Is this failure part of normal use, such as a typo in a form, rather than a full disk or a lost connection?” Yes, it happens every day and “Does the caller need to know why it failed, to show it or to act on it?” Yes, the reason matters: rules out Return null. null cannot say which rule failed, so the caller could not show the reason.
- “Is this failure part of normal use, such as a typo in a form, rather than a full disk or a lost connection?” Yes, it happens every day and “Does the caller need to know why it failed, to show it or to act on it?” No, absence is enough: adds a caveat to Return a sealed result. When absence is the only outcome, such as a lookup that finds nothing, a nullable return does the job with less code.
- “Is this failure part of normal use, such as a typo in a form, rather than a full disk or a lost connection?” Yes, it happens every day and “Is the code that can act on the failure many calls away from the check?” Yes, several layers up: adds a caveat to Return a sealed result and Return null. Every layer between the check and the handler must return the value on; that is visible in the types, and it is work.
The short version: throw an exception: An object a Dart program throws to report a failure it is expected to handle, such as text that does not parse or a file that is missing. Classes that implement Exception can be caught by type with an on clause. More in “Dart exceptions and errors: try, catch, finally” for a failure outside normal use, such as a full
disk or a lost connection, and let a caller several layers up catch it. Return null when absence is the only outcome
and needs no reason, such as a lookup that finds no user. Return a result type: A type whose values are either a success holding a value or a failure holding a reason, such as Ok and Bad, so that expected failures are returned instead of thrown. More in “Validating JSON in Dart with patterns” when the
failure is an everyday outcome whose reason the caller must show, such as a refused form field. A bug is none of
these: it stays an Error that reaches the top.
Good error handling design also decides what happens around those calls. Input validation decides the order of the
checks on untrusted input: Data that comes from outside the program, such as a request body, a file or a message, and may have any shape or content until the program has checked it. More in “Validating JSON in Dart with patterns”: its size first, then its type, then an allowlist: A list of what is accepted, such as the field names an endpoint takes. Anything not on it is refused, instead of trying to list everything that should be blocked. More in “Validating JSON in Dart with patterns” of
the characters it may hold. An assert cannot do input validation, because dart run ignores
assertion: A check written as assert(condition, message) that throws an AssertionError when the condition is false, but only while assertions are enabled, as with --enable-asserts or in Flutter debug mode.. A boundary at the
edge of the program turns each failure into a message, a log line and an exit code.
Key idea
Throw for failures outside normal use, return null when absence needs no reason, and return a sealed result when the caller must show or act on the reason.
Checkpoint
Check: Choosing by what a failure means
Validating untrusted input in a safe order, with measured runs Pro: opens with a Pro, Premium or Ultimate pass
Untrusted text can be long, malformed or written to break a check. This part puts the checks in the order that costs least, measures what a size limit saves, compares three rules on real names and shows why assert cannot guard input.
In this part:
- Check the size first: a 1 MB name costs nothing to refuse
- An allowlist accepts the real names a denylist refuses
- assert is not input validation
1 diagram · 4 runnable examples (Dart, Python) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Messages, logs and one boundary that sets the exit code Pro: opens with a Pro, Premium or Ultimate pass
A person needs to know what to do next; whoever fixes the system needs the details, and neither needs the other’s text. This part splits one failure between the two audiences, then gives a command line program one boundary that decides every message and exit code.
In this part:
- A message for the person, the details for the log
- One boundary in main maps each failure to an exit code
1 diagram · 3 runnable examples (Dart, Python) · about 6 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
The self-check on reporting a failure and choosing how starts with whichever of the two ideas you know less well, and a question you miss returns once before the session ends:
Practice that adapts to you
Practise this lesson
5 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (2-5 years): When should a Dart function return null instead of throwing?
Return null when “nothing there” is a normal answer and the caller needs no reason: a lookup by id that finds no user, a cache miss, an optional setting that is not set. The nullable return type then makes every caller handle the absent case before using the value, which an exception does not. Throw instead when the failure is outside normal use, such as a lost connection or a corrupt file, or when the caller must know why it failed: a bare null cannot tell one cause from another, which is when a sealed result type with a reason fits better.
Three longer questions put these choices to work on real designs:
-
Mid to senior
A ticketing library's parseBookingRef(String text) throws FormatException for every bad input, and each app shows "invalid reference". Support says users cannot tell a typo from an expired code. Redesign the API so callers can show the right message for each problem without making the common case harder.
-
Mid to senior
A signup endpoint checks names with a regular expression full of nested groups. A script sends names of 2 MB, and each request now takes about 4 seconds of CPU. Fix the validation, explain the order of the checks, and say how you would test the fix.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What are the three ways a Dart function can report a broken rule?
Throw an exception, return null, or return a sealed result. Only the sealed result both makes the caller handle the failure and carries the reason.
-
What does a thrown exception not tell the caller of checkUsername?
That it has to catch anything: nothing in the function's type says so. The exception carries the reason, but only a sealed result's switch has to name every outcome before it compiles.
-
Why can a nullable return be the wrong way to report a refused username?
Its String? type forces a null check, but 'A' and 'ravi.kumar' both came back as a bare null: the reason was lost.
-
When should code throw, return null or return a sealed result?
Throw for a failure outside normal use, such as a full disk or a lost connection; return null when absence needs no reason, such as a lookup that finds no user; return a sealed result when the caller must show or act on the reason.
-
Should a bug ever come back as a result value?
No. A bug is none of the three reports: it stays an Error that reaches the top, where the code gets fixed.
-
In which order should code check untrusted input?
Its size first, then its type, then an allowlist of the characters it may hold. An assert cannot do this, because dart run ignores assertions.
Key takeaways
- One failed check can be reported as an exception, a nullable return or a sealed result, and only the sealed result both forces the caller to handle it and carries the reason.
- Throw for failures outside normal use, return null when absence needs no reason, and return a sealed result for everyday outcomes the caller must explain.
- Errors that report bugs are never returned as results: they reach the top, where the code gets fixed.
Terms in this lesson
- assertion
- A check written as assert(condition, message) that throws an AssertionError when the condition is false, but only while assertions are enabled, as with --enable-asserts or in Flutter debug mode.
- denylist
- A list of values or characters that a check refuses, with everything else accepted. It guesses what bad input looks like, the opposite of an allowlist.
- error boundary
- One place near the edge of a program, such as a try in main, where failures from the code below are turned into messages, log lines and exit codes or responses.
References
- Error handling (The Dart project (Google))
- Effective Dart: Usage (Error handling) (The Dart project (Google))
- Exception class (The Dart project (Google))
- RegExp class (The Dart project (Google))
- exitCode property (dart:io) (The Dart project (Google))
- exit function (dart:io) (The Dart project (Google))
- logging package (The Dart project (Google))
- Input Validation Cheat Sheet (OWASP Foundation)
- Error Handling Cheat Sheet (OWASP Foundation)
- Logging Cheat Sheet (OWASP Foundation)
- RFC 9110: HTTP Semantics (status codes) (IETF)
- sysexits(3): preferable exit codes for programs (The FreeBSD Project)
- Unicode Standard Annex #44: Unicode Character Database (General_Category) (Unicode Consortium)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress