In Dart code review, how do you decide between a ?? fallback, a descriptive throw, and ! for a value that is nullable in its type?
answer
- is absence normal or a bug?
- default only if it is truthful
- ?? (throw StateError(...))
- validate at the data boundary
- ! only for local invariants
basics
~20 sIn Dart, ask whether null is a normal state: if so, handle it with ?? and a truthful default or a guard; if it means a bug, fail loudly with ?? (throw StateError('...')). Reserve ! for invariants proven right beside it.
solid answer
~50 sI start with what `null` means for that value. If absence is **normal** — a profile without a theme override — I handle it with `?.` and `??` and a default that is actually true for the product, like the system theme. If absence means **a bug or broken data** — a saved order without an id — a default would hide it, so I fail loudly with `order.id ?? (throw StateError('saved order has no id'))`, or better, reject the record where it enters the app so later code gets a non-nullable type. `!` is acceptable only when the invariant is established right next to it and cannot be expressed in types; otherwise it trades a compile-time question for a production `TypeError` with no explanation. I also watch the opposite mistake: a reflexive `?? 0` or `?? ''` that turns missing data into wrong data.
code
dart · 23 linesclass ProfileDto {
ProfileDto({this.userId, this.theme, this.fontScale});
final String? userId; // required by the product
final String? theme; // optional override
final double? fontScale; // optional override
}
class UserProfile {
UserProfile({required this.userId, required this.theme, required this.fontScale});
final String userId;
final String theme;
final double fontScale;
}
UserProfile toProfile(ProfileDto dto) {
return UserProfile(
// Required: absence is bad data, so fail with a message.
userId: dto.userId ?? (throw FormatException('profile without userId')),
// Optional: the defaults are true product behaviour, decided once here.
theme: dto.theme ?? 'system',
fontScale: dto.fontScale ?? 1.0,
);
}go deeper
Recall that ?? gives a default, a throw reports a broken rule, and ! crashes with a generic TypeError, so each fits a different meaning of null.
Explain why a default must be truthful, how ?? (throw ...) keeps a non-nullable type, and why optional callbacks use ?.call().
Show the boundary pattern: validate once, convert to non-nullable models, keep defaults in one place, and justify each remaining ! in review.
Weigh strict validation that rejects records against lenient defaults that keep screens working, and decide who owns those product defaults.
## The question behind every nullable value Every time code meets a `T?`, it must answer one question: **what does `null` mean here?** The operator follows from the answer, not from what makes the analyzer quiet fastest. | What null means | Example in a user profile | Tool | |---|---|---| | a normal, expected absence with a truthful default | no theme override → use the system theme | `?.` chain ending in `??` | | a normal absence that changes behaviour | no notification settings → skip the section | guard: `if (x == null) return …;` (promotion) | | a broken invariant or bad data | saved profile without a user id | `?? (throw StateError('…'))`, or reject at the boundary | | an invariant established right here that types cannot express | error message present exactly when status is not OK | `!`, ideally with a comment, or a descriptive throw | ## When a fallback is right, and when it lies A **fallback** with `??` is right when the default is **true for the product**: an unset font scale really means 1.0, a missing theme override really means "follow the system". The fallback then encodes a business rule, and it should live in one place — usually where the data is read — so two screens do not default the same field differently. A fallback **lies** when the value is required. `price ?? 0` turns a missing price into a free item; `userId ?? ''` sends an empty id to the server; `email ?? true` for a consent flag opts someone in. These compile, pass the analyzer and silently corrupt behaviour. The reviewer's test: *if this default ever fires, will anyone notice?* If the honest answer is "no, and that would be bad", the code needs a failure, not a fallback. ## Failing loudly, usefully For a value that must be present, compare the two ways to crash: ```dart final idOrCrash = order.id!; // TypeError: Null check operator used on a null value final id = order.id ?? (throw StateError('saved order has no id')); // StateError naming the invariant ``` Both stop execution; the second tells the person reading the crash report what was violated. The `throw` expression has type `Never`, so `id` is still a non-nullable `String`. Put parentheses around it on the right of `??`, as the SDK's own code does. ## Push nullability to the edge The best fix removes the question from most of the codebase: 1. **Validate at the boundary** where data enters — decoding a server response, reading preferences, parsing navigation arguments. Missing required fields become a reported error there. 2. **Convert to non-nullable types**: after validation, build a model whose required fields are `String`, not `String?`. 3. **Keep genuine options nullable** and give them their defaults in the same place. Downstream widgets and services then see non-nullable types and need no `!`, and the `??` defaults all live in one reviewed spot. ## Where ! still belongs - An invariant established **in the same few lines** that types cannot express — and even there a descriptive throw is often clearer. - **Tests**, where a crash is the desired signal. - It does **not** belong on data from outside the app, on optional callbacks (`?.call()` exists), or on values read after an `await` that might have changed. ## Review checklist - Does each `??` default describe real product behaviour, or does it hide missing data? - Does each `!` have an invariant a reader can verify within a few lines? - Are required values validated once, at the boundary, instead of asserted at every use? - Would a crash here be diagnosable from its message alone?
- In a Dart codebase, why can a reflexive ?? '' or ?? 0 be more dangerous than a !?A `!` fails loudly at the point of the bad assumption. A made-up default fails silently: the empty id or zero price flows on, gets stored or sent, and the bug surfaces later as wrong data with no stack trace pointing back to the cause.
- In a Flutter widget, why is ! on a value read after an await in a State method especially risky?While the method was suspended, the state could change — a field reset, a controller disposed, the widget removed. An assumption checked before the `await` may no longer hold after it, so `!` there turns a race into an occasional crash. Re-read and re-check after the `await` instead.
saying these in an interview costs you the question
- Adding ?? '' everywhere is the safest way to avoid null crashes.
- ! and ?? (throw ...) are equally informative when they fail.
- Every nullable field should get a default where it is used.
- ! is fine on server data because the API contract says it is present.
- Replacing every ! with ?? is always an improvement.