Dart syntax and null safety

var, final and const, sound null safety with ? and !, and the collection syntax you use in every file.

Declarations and inference

Dart is statically typed with inference. var infers the type once and then holds it, dynamic opts out of static checking, and final versus const is the difference between set-once at run time and known at compile time.

void main() {
  final name = 'Dart';      // inferred String, assigned once
  const pi = 3.14159;       // compile-time constant, usable in const contexts
  var count = 0;            // mutable, type fixed as int
  int? maybe;               // nullable: starts as null

  count += 1;
  print('$name $count');    // interpolation, no concatenation needed
  print(count.isEven);      // numbers are objects, never primitives
  print(pi.toStringAsFixed(2));
}

// A const list is deeply immutable; a final list can still be mutated
const frozen = [1, 2, 3];
final flexible = [1, 2, 3];
// frozen[0] = 9;  // compile-time error
flexible[0] = 9;

// var infers and locks; dynamic defers every check to run time
var typed = <String, int>{'a': 1};
dynamic loose = 42;
loose = 'now a string';      // accepted, because dynamic is dynamic
DeclarationMeaningUse for
final x = 1One assignment, value may be computed at run timeObjects created in a constructor body
const x = 1Compile-time constant, canonicalised and deeply immutableDefaults, keys, fixed data
var x = 1Inferred static type, reassignableLocals that change
Object? xAny value or nullHeterogeneous collections
dynamic xNo static checking at allInterop and JSON boundaries, sparingly
late final xAssigned once, but not at declarationExpensive fields and lazy initialisation

Dart uses two-space indentation by convention, semicolons are required, and the official formatter (dart format) is not optional in practice: every tool in the ecosystem assumes it.

Null safety

Sound null safety makes nullability part of the type. A String can never be null, a String? might be, and the compiler forces you to handle the difference before the program runs.

int? parseIntSafe(String raw) => int.tryParse(raw);   // null on failure

class Server {
  final String host;
  int port = 8080;                     // non-nullable needs an initialiser
  Server(this.host);
}

class Cache {
  late final List<String> entries = _load();   // computed on first read
  static List<String> _load() => <String>[];
}

void main() {
  // ?? supplies a fallback, ??= assigns only when null
  final parsed = parseIntSafe('nope') ?? 0;
  print(parsed + 1);                   // 1

  Server? server;
  print(server?.port);                 // null, no crash
  server ??= Server('example.com');    // promoted to non-null after this
  print(server.port);                  // 8080, no ! needed

  final cache = Cache();
  print(cache.entries.isEmpty);        // _load runs on this line
}
  • ?. short-circuits the whole chain: a?.b?.c is null if any link is null.
  • ! asserts non-null and throws at run time if you are wrong.
  • Flow analysis promotes locals after a null check, so if (x != null) x.length compiles without !.
  • Promotion does not apply to non-final fields or to getters, whose value could change between the check and the use.
  • late moves the null check to first use: reading before assignment throws LateInitializationError.
⚠️
Every ! you write throws away the guarantee null safety gave you and turns a compile error into a runtime Null check operator used on a null value. Reach for ??, a local variable, or a nullable return type first; keep ! for cases the analyser genuinely cannot see.

Collections and control flow

void main() {
  final numbers = <int>[1, 2, 3];
  final unique = <String>{'a', 'b', 'a'};        // Set: {'a', 'b'}
  final ages = <String, int>{'ada': 36};

  // Spread and collection-if/for build collections in one expression
  final merged = <int>[...numbers, 4, 5];
  final evens = <int>[for (final n in numbers) if (n.isEven) n];
  final copy = <String, int>{...ages, 'alan': 41};

  numbers.add(4);
  merged.removeWhere((n) => n > 4);
  final doubled = numbers.map((n) => n * 2).toList();
  final sum = numbers.fold<int>(0, (acc, n) => acc + n);

  print(merged.length);
  print(evens);              // [2]
  print(copy.keys.join(','));
  print(doubled.where((n) => n > 4).toList());
  print(sum);

  // switch is an expression, and it must be exhaustive
  final label = switch (numbers.length) {
    0 => 'empty',
    1 => 'single',
    _ => 'many',
  };
  print(label);

  for (var i = 0; i < 3; i++) {
    if (i == 1) continue;
    print('i=$i');
  }
}
  • List is growable by default; List.filled(n, 0) gives a fixed-length list.
  • Map preserves insertion order, so iteration order is predictable.
  • The cascade operator .. calls several methods on one object: list..add(1)..add(2).
  • Conditions must be bool: there is no truthiness, so if (list) will not compile.

FAQ

When should I use dynamic?
Almost never in application code. It is meant for interop and for JSON maps that arrive untyped; convert them to classes with explicit fields as soon as they cross into your domain, otherwise every typo becomes a runtime crash.
Why does my field not get promoted?
Promotion only applies to local variables that nothing can change in between. Fields and getters could return a different value on the next read, so assign to a local (final port = server.port;) or make the field final.

Classes, futures and streams Basic types and inference

Last refreshed 2026-09-18.