What is the difference between `?`, `!`, `late`, and `required` keywords in Dart?
Four keywords for working with Dart's null safety.
?— appended to a type to opt in to nullability.String?can be null or a String.
!— force-unwrap. Asserts the value is non-null; throws if it isn't. Almost always a code smell — defeats the type system. Use sparingly.
late— deferred initialization. Variable is non-nullable but assigned after declaration.late final useris idiomatic for 'init once ininitState, never again'.LateInitializationErrorif read before assigned.
required— modifier on named parameters; caller must pass them. Common on widget constructors:Box({required this.size, this.color = Colors.black}).
// `?` — makes a type nullable
String? maybeName; // can be null
int? age; // can be null or an int
// `!` — force-unwrap (assert not null, crash if null)
String? name = fetchName();
final len = name!.length; // CRASHES if name is null at runtime
// USE SPARINGLY — most uses are smells
// `late` — deferred initialization
late final User user; // promise: I'll init before any use
late String greeting = expensive(); // lazy: computed on first read
// Crash with LateInitializationError if read before assigned.
// `required` — named param MUST be passed
class Box {
Box({required this.size, this.color = Colors.black});
final double size;
final Color color;
}
// Box(color: Colors.red); // ❌ compile error: 'size' missing
// Box(size: 10); // ✅
// Combinations seen in real Flutter code:
class Profile extends StatefulWidget {
const Profile({super.key, required this.userId, this.compact = false});
final String userId; // required, non-null
final bool compact; // optional with default
@override
State<Profile> createState() => _ProfileState();
}
class _ProfileState extends State<Profile> {
late final ApiClient api; // init in initState
String? lastError; // nullable
@override
void initState() {
super.initState();
api = ApiClient(widget.userId); // assign late
}
void retry() {
final err = lastError!; // force-unwrap — caller proved non-null
print('Retry after $err');
}
}The first block shows each keyword in isolation; the final Profile widget combines them in the idiomatic way you'd see in production Flutter code. required this.userId forces callers to pass an id; this.compact = false provides a default; late final api is assigned exactly once in initState (immutability + deferred init); String? lastError is nullable for the optional error case.
The lastError! line is the only force-unwrap and should always be questioned — here it's only safe because the caller has already proven it non-null.
Senior signal: '! is almost always a smell' — force-unwrap means you've defeated the type system. Most uses should be replaced with pattern matching, ?? throw, or early-return guards. The other senior detail: late final is the idiomatic way to express 'I'll initialize this in initState and never again' — it gives you immutability and deferred init. Use late (non-final) sparingly — it can mask bugs.