In Dart, why does a promoted local variable lose its promotion after an assignment, inside a loop, inside a closure or across an await, and how do you restore it?
answer
- writes demote
- loops: the next iteration's write
- closures: any write disqualifies
- Dart 3.13: await or yield in closures
- fix: a fresh final local
basics
~20 sIn Dart, a promotion lasts only while flow analysis can prove no write intervened; a nullable assignment, a write in a loop body, a closure over an assigned variable, or (since 3.13) an await in such a closure demotes it. A fresh final local restores it.
solid answer
~50 sPromotion is a proof about one variable at one point, so anything that could change the variable undoes it. Assigning a `String?` to a promoted `middle` demotes it back to `String?` (assigning a `String` keeps it). In a loop, a write anywhere in the body cancels, at the top of the body, a promotion made before the loop, because the previous iteration may have made it. Closures are stricter: once a closure that **writes** the variable exists, it no longer promotes anywhere after that point, and **inside** a closure an outer promotion survives only if the variable is never assigned elsewhere in the function — even an earlier `middle ??= ''` breaks it. Since **Dart 3.13**, a closure that promoted such a variable also loses the promotion after its own `await` or `yield`. The fix is nearly always a new `final` local holding the checked value, which nothing else can write.
code
dart · 11 linesimport 'dart:async';
Future<void> saveName(String first, String? middle) async {
middle ??= ''; // a write: closures below can no longer rely on promotion
final m = middle; // m is a final String nobody else can write
unawaited(() async {
print(m.length);
await Future<void>.delayed(const Duration(milliseconds: 10));
print('$first $m'.trim()); // still String after the await
}());
}go deeper
Recall that assigning a nullable value to a checked variable undoes the check, and a new final local is the usual fix.
Explain why loops, closures and try blocks demote regardless of the value written, and when the right-hand side does matter.
Show you can read a non-promotion message, name the rule behind it, and restructure with a final local instead of adding ! to silence it.
Treat mutable locals captured by closures as a code smell a team can lint and review for, since they defeat the compiler's null proofs.
## Promotion is a proof, and writes break proofs **Flow analysis** promotes a variable — say `String? middle` to `String` — at the points where it can prove the value is not `null`. Every construct in this answer breaks the proof the same way: it creates a path on which the variable **might have been written** after the check. When that happens the variable is **demoted** to its declared type. ## Direct assignment ```dart if (middle == null) return; middle = lookupMiddleName(); // returns String? print(middle.length); // ERROR: demoted to String? ``` In straight-line code flow analysis looks at the **right-hand side**: assigning a `String` keeps the promotion, assigning a `String?` removes it. Combining branches often fixes cases where a human sees the paths never overlap but separate `if` statements hide that from the analysis — flow analysis does not correlate conditions across different `if` statements. ## Loops ```dart if (middle == null) return; while (true) { print(middle.length); // ERROR: demoted by the write below final next = nextName(); // returns String? if (next == null) break; middle = next; // non-null, but loops ignore the right-hand side } ``` Code at the **top of a loop body** can be reached from the end of the previous iteration, so a promotion made **before** the loop is lost if the body writes the variable anywhere, and in loops the analysis **ignores the right-hand side**. Re-check inside the body — a loop condition such as `while (middle != null)` does that on every iteration — or copy into a local declared inside the body. ## Closures Flow analysis cannot know **when**, or how often, a closure runs. It therefore applies three conservative rules, all ignoring the right-hand side: 1. **A closure that writes the variable** — once it is defined, the variable no longer promotes in the enclosing function, even after a fresh check. 2. **A closure that reads a promoted variable** keeps the promotion only if the variable is never assigned elsewhere in the function. A write **after** the closure breaks it, and so does one **before** it, such as `middle ??= '';`. 3. **Two closures**, one writing and one checking the variable, stop promotion inside the checking one, since they may interleave. ## Across await and yield (Dart 3.13) **Dart 3.13** closed a soundness hole: inside an `async` closure, a promotion of a variable that the **outer function writes** is dropped after the closure's own `await` or `yield`. While the closure is suspended, the outer function can keep running and change the variable. Earlier releases kept the promotion, which was unsound. The compiler's context message reads "could not be promoted due to an 'await' or 'yield'". ## try, catch and finally In a `catch`, the analysis assumes the exception might have been thrown at any point in the `try`, so a variable written anywhere in the `try` is demoted there, again ignoring the right-hand side. The same applies between `try` or `catch` and `finally`. ## The fix: give the proof a variable nobody else writes | Cause | Fix | |---|---| | nullable reassignment | re-check after it, or merge the `if` statements | | write in a loop | declare a `final` local inside the body and check it | | closure writes the variable | move the promotion before the closure, or copy first | | closure reads, variable written elsewhere | `final m = middle;` or `final m = middle ?? '';` outside, use `m` inside | | `await` in a closure (3.13) | copy into a `final` local inside the closure before the `await`, or re-check after it | | written in `try`, read in `catch` | null-check again inside the `catch` | A `final` local that is assigned once from a checked value **cannot be written by anyone**, so its promotion survives loops, closures and suspension. Reaching for `!` instead compiles, but it replaces a proof with a runtime assertion.
- In Dart, why does assigning a String keep a String? variable promoted in straight-line code but not inside a loop?In straight-line code flow analysis considers the right-hand side, so a non-nullable value keeps the variable promoted. For loops, closures and try blocks it ignores the right-hand side for historical and simplicity reasons, so any write demotes. A `final` local sidesteps both.
- In Dart, why is `final m = middle ?? '';` enough to use m inside a closure without checks?The initializer has type `String`, so `m` is inferred as a non-nullable `String` and, being `final`, can never be written again. There is nothing to promote and nothing to demote: the closure simply sees a `String`.
saying these in an interview costs you the question
- Once a variable is promoted, it stays promoted for the rest of the function.
- A write before a closure is defined cannot affect promotion inside it.
- Assigning a non-null value inside a loop always keeps the promotion.
- The Dart 3.13 await rule means promotion never survives any await.
- Adding ! is the recommended fix for a lost promotion.