Dart / Foundations: values, absence, collections, and valid objects / Lesson 3
Handle absence without hiding invalid input
Use nullable types, promotion, null-aware operators, and tryParse while keeping late initialization and assertions explicit.
On this page
Lesson goals
- Distinguish null, empty text, failed parsing, and a violated domain rule.
- Explain what ?, !, ??, and late promise at their respective boundaries.
Put optionality into the type
String excludes null under sound null safety, while String? allows a string or absence. An empty string is still a present string with length zero. Decide which meaning the domain needs before choosing a default. A missing optional note can be normal, but a missing required batch identifier may be an integration failure. Null safety helps the analyzer reject many unsafe operations; it does not validate that a present value follows a business format, and explicit assertions or delayed initialization can still cause runtime failures. Keep those distinct guarantees visible.
A null-aware access preserves absence
The ?. operator accesses a member only when its receiver is non-null. The ?? operator chooses its right operand only when the left value is null, not when it is empty or zero. The ! operator instead asserts that a nullable value is present and can fail if that promise is false. It should not be a routine way to silence analysis at an external-input boundary. The example contrasts missing and empty values with a safe fallback. Its output is reasoned from the fixed data rather than captured from a Dart execution.
void main() {
final String? missing = null;
final String? empty = '';
print(missing?.length ?? -1);
print(empty?.length ?? -1);
print(missing ?? 'no note');
}
-1
0
no note
Expected output is -1, 0, no note. The empty string is not null, so its zero length is retained. No runtime or analyzer was executed.
Let control flow establish a usable local type
After a suitable check, flow analysis can promote a value to a more specific type. An early return for null can make later use of a stable local value straightforward. Promotion depends on the language rules and whether the value can change; not every property or captured mutable variable can be treated like an unchanged local. Use a deliberate local snapshot when that is the intended meaning, rather than inserting ! everywhere. A snapshot is itself a semantic choice: if the business requires the current live property instead, reading it once may change the policy.
Parsing success does not prove domain validity
int.tryParse returns an int or null instead of throwing for ordinary parse failure. A parsed negative integer can still be invalid as a sample count. The helper below checks the positive-count rule after conversion and uses null for both rejection reasons. That is appropriate only when callers do not need detailed causes. A later typed outcome can distinguish malformed text from an unacceptable value. Trimming is an explicit input policy rather than a universal correction; do not apply the same normalization blindly to identifiers or signed protocol data.
int? positiveCount(String text) {
final value = int.tryParse(text.trim());
if (value == null || value <= 0) return null;
return value;
}
void main() {
for (final text in <String>[' 3 ', 'bad', '0']) {
print(positiveCount(text) ?? 'invalid');
}
}
3
invalid
invalid
Expected output is 3, invalid, invalid. Zero is syntactically valid but rejected by the positive-count rule. All strings are fixed synthetic inputs; the program was not run.
Late initialization moves an obligation to runtime
late can support a value initialized after declaration, and a late initializer can also defer work until first use. It does not establish that every lifecycle path performs initialization before a read. Reading an uninitialized late variable can fail at runtime. A late final binding also has a one-assignment obligation. Prefer constructor-required state when an object cannot be meaningful without the value. When delayed initialization is justified, name the owner and timing, test the premature-read path, and avoid exposing a partially prepared object as though its non-null fields were already ready.
Common errors and fixes
A null assertion fails on an ordinary missing field.
- Why it happens
- Expected absence was treated as an impossible internal state.
- How to fix it
- Handle absence explicitly through rejection, fallback, or an optional result.
- Prevent it next time
- Require a real invariant before adding a not-null assertion.
An empty note is mistaken for a missing note.
- Why it happens
- The application collapsed distinct meanings without a declared policy.
- How to fix it
- Check emptiness separately when the domain needs that distinction.
- Prevent it next time
- Include null, empty, whitespace-only, and ordinary values in boundary examples.
A late field is read before setup completes.
- Why it happens
- The initialization owner or lifecycle order was implicit.
- How to fix it
- Require construction-time data or enforce an explicit delayed-initialization protocol.
- Prevent it next time
- Test every path that can expose the object before it is ready.
Check your understanding
1. Why does value?.length ?? 0 lose information if a report distinguishes absent and empty text?
Show worked solution
Both cases become zero, so the report cannot recover which input occurred. Preserve a nullable length or an explicit state until the presentation layer decides how to show each case. A fallback is correct only when combining those meanings is intentional. Safe syntax alone does not prove that collapsing the data matches the product requirement.
2. Does late String id promise that an object always has a valid identifier?
Show worked solution
No. It defers definite initialization responsibility to runtime and does not validate identifier content. A read before assignment can fail, and an assigned empty string can still violate the domain. Prefer required constructor data when possible, then validate the actual format and ownership at the proper boundary. Delaying assignment is not a substitute for either check.
Key takeaways
- Null-aware operators preserve type safety only within their stated meaning.
- Presence, valid content, and lifecycle readiness require separate reasoning.