Variables, final & const
`var`, `final`, `const` and `late` — four ways to bind a name, and the compile-time versus run-time line that separates them.
Declaring
void main() {
var inferred = 'a String'; // type inferred, reassignable
String explicit = 'also a String';
final once = 'set once at run time';
const always = 'set once at compile time';
print('$inferred / $explicit / $once / $always');
inferred = 'changed';
print(inferred);
}var is not "dynamic" — the type is inferred and then fixed. Assigning an int to a var that was inferred as String is a compile error.
void main() {
var n = 1;
n = 2; // fine
print(n);
dynamic anything = 1;
anything = 'now a String'; // dynamic really does turn checking off
print(anything);
print(anything.runtimeType);
}⚠️ dynamic disables static checking, so anything.nonsense() compiles and throws NoSuchMethodError at run time. Object? is almost always what you actually want: it accepts everything but forces a check before use.
final versus const
final means "assigned once". const means "known at compile time" — a much stronger claim.
void main() {
final now = DateTime.now(); // fine: computed at run time
const answer = 42; // fine: a literal
print('$now is after the program started');
print('$answer was baked into the binary');
// const list contents are deeply immutable
const fixed = [1, 2, 3];
final growable = [1, 2, 3];
growable.add(4);
print(growable);
print(fixed);
}void main() {
const fixed = [1, 2, 3];
fixed.add(4); // error! Unsupported operation: Cannot add to an unmodifiable list
}Canonicalisation
Identical const values are the same object. The compiler creates one and shares it.
void main() {
const a = [1, 2, 3];
const b = [1, 2, 3];
final c = [1, 2, 3];
final d = [1, 2, 3];
print(identical(a, b)); // true — one shared instance
print(identical(c, d)); // false — two separate lists
print(a == b);
}const constructors
class Point {
final int x;
final int y;
const Point(this.x, this.y);
@override
String toString() => 'Point($x, $y)';
}
void main() {
const a = Point(1, 2);
const b = Point(1, 2);
final c = Point(1, 2);
print(identical(a, b)); // true
print(identical(a, c)); // false — `final` builds a new one
print('$a $b $c');
}A class can have a const constructor only if every field is final. That is the contract: the compiler must be able to build the whole object before the program runs.
late
late postpones initialisation while keeping the type non-nullable.
class Config {
late final String url; // assigned later, still non-nullable
void load() {
url = 'https://example.com';
}
}
void main() {
final config = Config();
config.load();
print(config.url);
// `late` also makes initialisation lazy: the expression runs on first read.
late final expensive = compute();
print('not computed yet');
print(expensive);
print(expensive); // computed once, reused
}
String compute() {
print(' …computing');
return 'the result';
}class Config {
late final String url;
}
void main() {
final config = Config();
print(config.url); // error! LateInitializationError: Field 'url' has not been initialized.
}⚠️ late moves a compile-time guarantee to a run-time one. It is right when a framework assigns the field after construction, or when the initialiser is genuinely expensive and might not be needed. It is wrong as a way to avoid thinking about nullability — String? and an explicit check are honest; a LateInitializationError in production is not.
Type inference in practice
void main() {
var list = [1, 2, 3]; // List<int>
var mixed = [1, 'two', true]; // List<Object>
var empty = []; // List<dynamic> — usually not what you want
var typed = <String>[]; // List<String>
var map = {'a': 1}; // Map<String, int>
var set = <int>{}; // Set<int>
var emptyMapNotSet = {}; // Map<dynamic, dynamic>!
print(list.runtimeType);
print(mixed.runtimeType);
print(empty.runtimeType);
print(typed.runtimeType);
print(map.runtimeType);
print(set.runtimeType);
print(emptyMapNotSet.runtimeType);
}{} is an empty map, not an empty set — the literal is ambiguous and Dart resolves it in favour of Map. Write <int>{} when you want a set.
Scope and shadowing
String top = 'library level';
void main() {
print(top);
var x = 'outer';
{
var x = 'inner'; // shadows the outer one
print(x);
}
print(x);
for (var i = 0; i < 3; i++) {
// each iteration gets its OWN i — closures capture the right one
}
final callbacks = <void Function()>[];
for (var i = 0; i < 3; i++) {
callbacks.add(() => print('captured $i'));
}
for (final c in callbacks) {
c();
}
}Dart's for loop gives each iteration a fresh variable, so closures created inside it capture distinct values — the behaviour JavaScript only gets with let.
Reference: Variables on dart.dev.