Dart — Variables, Type System & Memory Semantics
var / final / const / late — Allocation & Canonicalization
dart
// ── var: type inferred at compile time, mutable reference ──
var count = 0; // inferred as int — the TYPE is fixed, the VALUE is mutable
count = 42; // ✓ same type, reassignment OK
// count = 'hi'; // ✗ compile error: int expected — inference is not dynamic
// ── final: single-assignment, value computed at RUNTIME ──
final now = DateTime.now(); // ✓ evaluated at construction, locked thereafter
// now = DateTime.now(); // ✗ throws: late initialization already happened
// ── const: compile-time constant, CANONICALIZED across the program ──
const pi = 3.14159; // the compiler interns this — identical const expressions
const pi2 = 3.14159; // are the SAME object in memory (identical(pi, pi2) == true)
const list = [1, 2, 3]; // deeply immutable, canonicalized — same instance everywhere
// list.add(4); // ✗ UnsupportedError: const lists are immutable
// ── late: non-nullable, assigned before first read, or lazy initializer ──
late final String config = _loadConfig(); // _loadConfig() runs ONLY on first read
// Reading `config` before assignment throws LateInitializationError.
// With an initializer (above), it's always safe — runs lazily, caches result.
String _loadConfig() {
print('Config loaded'); // runs once, on first access
return 'production';
}
const Canonicalization — Memory Sharing
dart
// const objects with identical arguments are the SAME instance (canonicalized).
// The compiler deduplicates them — zero allocation at runtime for repeated uses.
const a = [1, 2, 3];
const b = [1, 2, 3];
print(identical(a, b)); // true — same object in memory (not just equal)
// This extends to const constructors:
class Color {
final int r, g, b;
const Color(this.r, this.g, this.b);
}
const red1 = Color(255, 0, 0);
const red2 = Color(255, 0, 0);
print(identical(red1, red2)); // true — canonicalized at compile time
// Non-const: separate allocations, identity comparison is false.
final red3 = Color(255, 0, 0);
final red4 = Color(255, 0, 0);
print(identical(red3, red4)); // false — distinct heap allocations
print(red3 == red4); // false (unless == is overridden)
final vs const — The Deep Immutability Trap
dart
// ❌ Anti-pattern: assuming `final` makes collections immutable.
final list = [1, 2, 3];
list.add(4); // ✓ `final` locks the REFERENCE, not the contents.
list[0] = 99; // ✓ mutation is allowed — `final` ≠ immutable.
// list = [0]; // ✗ reassignment is blocked.
// ✓ Correct: use `const` for compile-time immutable, or List.unmodifiable for runtime.
const immutable = [1, 2, 3]; // deeply immutable, canonicalized
// immutable.add(4); // ✗ UnsupportedError
final readonly = List.unmodifiable([1, 2, 3]); // runtime immutability
// readonly.add(4); // ✗ UnsupportedError — view wrapper throws
// List.unmodifiable is a VIEW — mutations to the source are visible:
final source = [1, 2, 3];
final view = List.unmodifiable(source);
source.add(4);
print(view); // [1, 2, 3, 4] — the view reflects the source mutation!
// For a true immutable copy: List.unmodifiable([...source])
Built-in Types — Platform-Dependent Semantics
Numbers: int, double, num
dart
// ── Native (VM/AOT): int is 64-bit signed, double is IEEE 754 64-bit ──
// ── Web (dart2js/DDC): both are JS numbers (IEEE 754 doubles) ──
int max64 = 9223372036854775807; // 2^63 - 1 on native
max64 + 1; // native: wraps to -9223372036854775808 (two's complement, SILENT)
// web: becomes 9223372036854775808.0 (a double, precision lost above 2^53)
// The web precision trap:
int big = 1 << 53; // 9007199254740992
print(big + 1 == big); // native: false | web: true (double can't represent 2^53+1)
// Use BigInt for arbitrary precision (works on all platforms):
BigInt huge = BigInt.parse('9223372036854775807') + BigInt.one;
print(huge); // 9223372036854775808 — exact, no overflow
// num is the supertype of int and double:
num any = 10; // can hold int or double
any = 5.5; // ✓
// any.abs(); // ✓ num has abs()
// any.toRadixString(16); // ✗ int-only method — requires `as int` or `is int` check
Integer Division & Modulo — Sign Semantics
dart
// `/` ALWAYS returns double, even for int operands:
print(7 / 2); // 3.5 (double)
print(4 / 2); // 2.0 (double, NOT int 2)
// `~/` is integer division (truncates toward ZERO, not floor):
print(7 ~/ 2); // 3
print(-7 ~/ 2); // -3 (truncates toward zero — NOT -4 like Python's floor division)
// `%` follows the sign of the DIVIDEND (like C, unlike Python):
print(7 % 3); // 1
print(-7 % 3); // -1 (Dart: sign of dividend | Python: 2, sign of divisor)
print(7 % -3); // 1 (sign of dividend)
// To get Python-style non-negative modulo:
int pyMod(int a, int n) => ((a % n) + n) % n;
print(pyMod(-7, 3)); // 2
Strings — Internals & Interpolation
dart
// Dart strings are sequences of UTF-16 code units (NOT code points / graphemes).
// A single emoji may be 2 code units (surrogate pair):
var emoji = '🎉'; // U+1F389
print(emoji.length); // 2 — two UTF-16 code units
print(emoji.runes.length); // 1 — one Unicode code point
print(emoji.codeUnits.length); // 2 — code units
// ❌ Anti-pattern: indexing strings for "characters":
var s = 'café';
print(s[3]); // 'é' — works here (1 code unit)
var s2 = 'café🎉';
print(s2[4]); // garbage surrogate — NOT the emoji
print(s2[5]); // second half of surrogate pair — NOT a character
// ✓ Correct: use runes for code points, or `characters` package for graphemes:
for (var rune in s2.runes) {
print(String.fromCharCode(rune)); // c, a, f, é, 🎉
}
// String interpolation calls toString() — override for readable output:
class Point {
final double x, y;
const Point(this.x, this.y);
@override
String toString() => 'Point($x, $y)'; // without this: 'Instance of Point'
}
var p = Point(1, 2);
print('Location: $p'); // 'Location: Point(1.0, 2.0)'
print('Sum: ${p.x + p.y}'); // 'Sum: 3.0' — ${} for expressions
Object vs dynamic vs Object? — Type Safety Boundaries
dart
// ── Object: non-nullable supertype of all non-null types ──
// Static type checking is ON — you must cast to access methods.
Object obj = 'hello';
// obj.toUpperCase(); // ✗ compile error: Object has no toUpperCase
(obj as String).toUpperCase(); // ✓ explicit cast — throws if wrong type
if (obj is String) {
obj.toUpperCase(); // ✓ type promotion: obj is String in this block
}
// ── dynamic: disables ALL static type checking ──
// Any method call compiles — checked at RUNTIME (NoSuchMethodError if missing).
dynamic dyn = 'hello';
dyn.toUpperCase(); // ✓ compiles, works at runtime
dyn = 42;
dyn.toUpperCase(); // ✓ compiles, throws NoSuchMethodError at runtime (int has no toUpperCase)
dyn.nonExistent(); // ✓ compiles, throws at runtime
// ── Object?: nullable supertype of ALL types (including Null) ──
Object? maybe = null; // ✓
maybe = 'hello'; // ✓
Object notNull = null; // ✗ compile error: Object is non-nullable
// ❌ Anti-pattern: using `dynamic` for "I don't know the type."
// It defeats the entire type system — bugs surface at runtime, not compile time.
// ✓ Correct: use `Object` (non-null, type-safe, must cast) or `Object?` (nullable).
// Use `dynamic` ONLY for JSON parsing interop or JS interop — never in APIs.
Type Conversion — Safe Parsing Patterns
dart
// ❌ Anti-pattern: `parse` on untrusted input — throws FormatException.
int parseUnsafe(String input) => int.parse(input); // throws on 'abc'
// ✓ Correct: `tryParse` returns null on failure — handle explicitly.
int? parseSafe(String input) => int.tryParse(input);
void handleAge(String input) {
final age = int.tryParse(input);
if (age == null || age < 0 || age > 150) {
throw ArgumentError('Invalid age: $input');
}
// ... use age
}
// double ↔ int conversions:
print(3.7.toInt()); // 3 (truncates toward zero)
print(-3.7.toInt()); // -3 (truncates toward zero, NOT floor)
print(3.0.toInt()); // 3
// print(3.7.toInt() == 3.7.floor()); // false for negatives: toInt=-3, floor=-4
// toString with formatting:
print(3.14159.toStringAsFixed(2)); // '3.14'
print(255.toRadixString(16)); // 'ff'
print(255.toRadixString(2)); // '11111111'
Records (Dart 3) — Value-Semantic Anonymous Types
dart
// Records are immutable, value-equal, zero-allocation anonymous aggregates.
// Fields are positional by default, named with a label.
({String name, int age}) user = (name: 'Alice', age: 30);
(int x, int y) point = (3, 4);
// Value equality is automatic — no need to override == or hashCode:
var a = (x: 1, y: 2);
var b = (x: 1, y: 2);
print(a == b); // true — structural equality, built in
print(identical(a, b)); // false — distinct instances, but equal
// Destructuring with patterns:
var (x, y) = point; // x = 3, y = 4
var (:name, :age) = user; // name = 'Alice', age = 30
// Use records instead of tiny one-off classes:
({User user, List<Repo> repos}) fetchProfile(String username) async {
// ... no need to define a Profile class
return (user: u, repos: r);
}
💡 Tips & Tricks
- Performance:
constobjects are canonicalized at compile time — usingconstin hot paths (Flutter widget trees, lookup tables) means zero allocation and zero GC pressure. Preferconstoverfinalwhen the value is known at compile time. - Idiom:
late final x = expensive()defers computation until first read — the initializer runs once and caches. Use for expensive fields that may never be accessed (e.g., a debug-only inspector). - Idiom:
Objectoverdynamicfor "any non-null value" —Objectpreserves static type checking (you must cast), whiledynamicsilently disables it.dynamicshould only appear at JSON/JS interop boundaries. - Portability: use
BigIntfor integers above 2^53 that must work on web —inton web is a JS double and loses precision. On native,intis 64-bit and safe up to 2^63-1. - Debug:
print(obj)callsobj.toString(). Always overridetoString()on domain objects — the default'Instance of Foo'is useless in logs and error messages.
⚠️ Edge Cases & Gotchas
- Only
true/falseare boolean:if (0),if (''),if (null),if ([])are all compile errors. Dart has no truthy/falsy coercion (unlike JS). Onlyif (bool)is valid. {}is an emptyMap, not aSet:var x = {}infersMap<dynamic, dynamic>. Usevar x = <int>{}orSet<int>()for an empty Set.finaldoesn't freeze collections:final list = [1,2,3]; list.add(4)works —finallocks the reference, not the contents. UseconstorList.unmodifiable([...source])for true immutability.List.unmodifiableis a view: it wraps the source list. Mutating the source is visible through the view. UseList.unmodifiable([...source])for an immutable copy.- Reading a
latevariable before assignment throws:late int x; print(x);→LateInitializationError. Uselate final x = initializerto guarantee safe lazy initialization. constrequires compile-time-known values:const x = DateTime.now()fails.finalaccepts runtime values.constinside afinalcontext is fine:final x = const [1,2,3].inton web loses precision above 2^53:int.parse('9007199254740993')on web gives9007199254740992(rounded double). UseBigInt.parsefor exact large integers on web.- String
.lengthis UTF-16 code units, not characters:'🎉'.lengthis2(surrogate pair). Use.runes.lengthfor code points, or thecharacterspackage for grapheme clusters. - Integer division
~/truncates toward zero:-7 ~/ 2 = -3(not -4).%follows the sign of the dividend:-7 % 3 = -1(not 2 like Python). toString()onnum:3.0.toString()is'3.0'(not'3'). UsetoInt().toString()ortoStringAsFixed(0)for'3'.
🧠 Spot the Bug
A developer caches configuration with final, expecting it to be immutable. Another part of the code mutates it:
dart
class Config {
final Map<String, String> settings = {'env': 'prod', 'debug': 'false'};
void override(String key, String value) {
settings[key] = value; // mutates the "final" map
}
}
void main() {
final config = Config();
config.override('env', 'dev');
print(config.settings['env']); // 'dev' — the "final" map was mutated!
}
What's wrong and how to fix it?
Answer
final locks the reference (settings can't be reassigned), but the Map itself is mutable. settings[key] = value mutates the contents — the "final" map is changed. final is not deep immutability.
The fix — use Map.unmodifiable (runtime) or const (compile-time):
class Config {
// Runtime immutable — throws on any mutation attempt.
final Map<String, String> settings = Map.unmodifiable({'env': 'prod', 'debug': 'false'});
void override(String key, String value) {
// settings[key] = value; // ✗ UnsupportedError at runtime
throw UnsupportedError('Config is immutable — create a new instance to override.');
}
}
// Or compile-time immutable (if values are literals):
class Config {
const Config();
static const Map<String, String> settings = {'env': 'prod', 'debug': 'false'};
}
final = single-assignment reference. const = deeply immutable + canonicalized. List/Map.unmodifiable = runtime immutable view. Choose based on whether the value is known at compile time (const), constructed at runtime (unmodifiable), or genuinely mutable (final + mutable collection).