skip to content

In Dart's sound null safety, what is the difference between String and String?, and what does 'sound' guarantee at run time?

level: juniorimportance: must knowfreq 78%

answer

  1. non-nullable by default
  2. String? is String or Null
  3. String is a subtype of String?
  4. only Null's members on String?
  5. dynamic is checked at run time

basics

~20 s

In Dart, String can never hold null while String? holds a String or null, so members like length need a check first. Sound means the guarantee holds at run time: an expression whose static type is non-nullable never evaluates to null.

solid answer

~40 s

Dart types are **non-nullable by default**: a `String` variable always holds a string, and `String?` means a string or `null` — in effect `String | Null`. `String` is a subtype of `String?`, so passing a `String` where `String?` is expected is always fine, but the reverse is a compile-time error until you prove the value is non-null. On a `String?` you can only use what `Null` also has — `toString()`, `==`, `hashCode` — plus extensions on the nullable type. **Sound** means the static type is a promise the runtime keeps: an expression typed `String` never evaluates to `null`. Where static checking can't see — a value typed `dynamic`, an `as` cast — Dart inserts a runtime check that throws a `TypeError` instead of letting `null` in. Since Dart 3.0 there is no unsound mode.

code

dart · 12 lines
dart
String fullName(String first, String? middle, String last) {
  // print(middle.length); // compile-time error: middle may be null
  if (middle == null) return '$first $last';
  return '$first $middle $last'; // middle is String here
}

void main() {
  final Map<String, dynamic> json = {'first': 'Ada'};
  print(fullName('Ada', null, 'Lovelace')); // Ada Lovelace
  String last = json['last']; // throws TypeError: type 'Null' is not a subtype of type 'String'
  print(last);
}

go deeper

for a junior

Recall that types are non-nullable by default, that ? adds null as a possible value, and that a String? needs a check before you use it.

for a middle

Explain the subtype relation between T and T?, which members a nullable type allows, and where Dart checks at run time to keep soundness.

for a senior

Show where nulls really enter an app — decoded JSON, platform results, dynamic values — and how you convert them to non-nullable types at that boundary.

for a principal

Discuss how the non-nullable-by-default guarantee shapes API design across a codebase: which types are allowed to be nullable, and where validation lives.

## Two types, not one with a flag Under **sound null safety**, every Dart type comes in two forms: | | `String` | `String?` | |---|---|---| | Values it holds | any string | any string, or `null` | | Default for an uninitialized variable | none — must be assigned before use | `null` | | Members you can call | every `String` member | only members `Null` also has, plus extensions on `String?` | | Assign to the other | always allowed | compile-time error until proven non-null | `String?` is, in effect, the union `String | Null`. **`Null` is no longer a subtype of every type**, so types such as `String`, `int` or `Person` reject `null`; you opt in by writing `?`. Because `String` is a **subtype** of `String?`, widening from non-nullable to nullable is free; narrowing needs proof. ## What non-nullable by default demands Once `null` is not a free default, every non-nullable variable needs a real value before it is read: - **Top-level variables and static fields** of non-nullable type need an initializer. - **Instance fields** need an initializer, an initializing formal (`this.name`) or an initializer-list entry — a value before the constructor body runs. - **Local variables** may be declared without one, as long as they are **definitely assigned** before use. - **Optional parameters** of non-nullable type need a default value; otherwise make them nullable. In the optional-middle-name example, `String first` and `String last` are required, and `String? middle` is the one that may be missing: ```dart String fullName(String first, String? middle, String last) { if (middle == null) return '$first $last'; return '$first $middle $last'; } ``` Calling `middle.length` before the check is a compile-time error (`unchecked_use_of_nullable_value`); after it, flow analysis has **promoted** `middle` to `String`. ## What "sound" adds Many type systems let a nullable value slip into a non-nullable slot at run time. Dart's null safety is **sound**: if the static type of an expression is non-nullable, it is guaranteed never to evaluate to `null` at run time. Two consequences matter in practice: 1. **Runtime checks at the edges.** Where the analyzer cannot see the value — reading from a `Map<String, dynamic>` decoded from JSON, an `as` cast — Dart checks when the value enters a non-nullable slot. `String name = json['name'];` with a missing key throws a `TypeError` ("type 'Null' is not a subtype of type 'String'") on that line, not a confusing failure three calls later. 2. **Compilers can trust the types.** Because non-null is guaranteed, the compilers can drop null checks, which the Dart docs credit with smaller binaries and faster execution. ## Dart 3 and older code Since **Dart 3.0** (May 2023) sound null safety is the only mode: code that is not null-safe no longer compiles, and there is no mixed "unsound" mode left. Answers that mention running with unsound null safety describe the Dart 2.12–2.19 migration era. ## Typical interview traps - Saying `String?` is a wrapper object like an optional box. It is the same string value; only the static type differs. - Claiming `null` can still reach a `String` variable through `dynamic`. It cannot: the assignment throws a `TypeError` at that point. - Forgetting that `String` flows into `String?` freely while the reverse needs a check, a fallback or an assertion.

  • In Dart, why can you call toString() and == on a String? without a null check?
    Because `Null` has them too. On a nullable type Dart only allows members that both the underlying type and `Null` define — `toString()`, `==`, `hashCode` and the other `Object` members — so the call is safe whatever the value is. Extension methods declared on `String?` are allowed for the same reason.
  • In Dart, what happens at run time when a Map<String, dynamic> value that is null is assigned to a String variable?
    The assignment compiles, because `dynamic` may be implicitly cast, but the runtime checks the value at that point and throws a `TypeError` saying `Null` is not a subtype of `String`. Soundness is kept by failing early, at the boundary where the null tried to enter.

saying these in an interview costs you the question

  • String? is a wrapper object around a String, like an Optional box.
  • A String variable can still end up null at run time through dynamic.
  • String? is a subtype of String, so it can be passed where String is expected.
  • Dart 3 still lets you run code with unsound null safety.
  • Non-nullable local variables must always be initialized where they are declared.