Variables & Types
By the end of this lesson you can explain — and prove with real Dart output — what a variable actually is in memory, why Dart's type system stops whole classes of bugs before your program ever runs, when to use var, final, const, late and dynamic, and how to check and convert types safely.
1. What is a variable?
A variable is exactly that tag: a name that currently refers to a value living somewhere in the computer's memory. The name and the value are two different things. This matters because three different actions get confused by beginners:
- Declaration — telling Dart "this name exists from now on":
int x; - Assignment — putting a value into a name that already exists:
x = 5; - Initialization — declaring AND assigning in one statement (the normal case):
int x = 5;
A later x = 8; on its own is a reassignment: no new tag is created, the same tag "x" is simply moved to point at a different value. What happens to the old value depends on what kind of value it is — a plain number just gets overwritten in place, but an object (like a List) keeps existing on the heap until nothing points to it any more, at which point Dart's garbage collector reclaims the memory. (Two words used here, defined: the stack is the small, tidy desk of named boxes that belongs to the function currently running; the heap is the big shared storage room where objects such as Lists live, and the stack box then holds just an arrow — a reference — to the object. The garbage collector is Dart's cleaner that throws away heap objects no arrow points to any more.)
The same thing as runnable Dart — declaration, initialization, assignment, x = x + 1 and the reference-copy of a List (exact output shown in the comments, verified):
Input size → what's feasible: a declaration or assignment is O(1) at any scale; copying a variable that holds a List copies one reference (O(1)), never the n elements — so oldA = a costs the same for n = 2 or n = 106 items.
x = x + 1; is "impossible" ("how can x equal x plus one?"). It isn't an equation — = in Dart means "evaluate the right side first, then store the result into the box named on the left." Read it right-to-left.2. Types: what they are, and Dart's core types
null), this is called a sound type system — the compiler's promises about types are guarantees, not hints.Two moments to keep apart: compile time is when Dart (or your editor's analyzer) reads and checks your code before anything runs; runtime is while the program is actually executing. A static type is the type the compiler knows from the code; the runtime type is the type of the actual object found in memory when the line executes.
Dart's everyday built-in types:
| Type | Holds | Example literal |
|---|---|---|
int | whole numbers (64-bit two's-complement — a binary format for representing negative numbers, detailed in the Numbers & Bits lesson — on native VM/AOT; behaves like a JS number on the web — this matters for very large integers) | 42 |
double | decimal / floating-point numbers | 3.14 |
num | the supertype of int and double — use when either is fine | num n = 5; n = 5.5; |
String | text | 'hello' |
bool | true / false | true |
List | an ordered, indexable sequence of values (covered fully in a later lesson) | [1, 2, 3] |
Map | a set of key → value pairs | {'a': 1} |
Set | a collection of unique values, no duplicates | {1, 2, 3} |
The same thing as runnable Dart — every core type from the table, the num parent type, and the type hierarchy checks (exact output shown in the comments, verified):
You can write the type explicitly, or let Dart figure it out with type inference using var:
The same thing as runnable Dart — var locks in the inferred type; an empty literal falls back to List<dynamic> (exact output shown in the comments, verified):
Input size → what's feasible: inference is resolved by the compiler before the program runs, so it costs nothing at runtime for any n.
var is not "no type" — Dart looks at the value on the right of = once, at the point of declaration, and locks the variable to that inferred type forever. var x = 1; makes x permanently an int — assigning a double to it later is a compile error, exactly as if you had written int x = 1;. An explicit annotation (int x = 1;) does the same thing, just spelled out — useful for public APIs and for readability when the right-hand side is complex.
var is purely a compile-time convenience. By the time your code is compiled to native or JS, there is no difference at all between a var-declared and an explicitly-typed variable — both carry the same fixed static type.3. dynamic vs Object vs Object? — the danger zone
null) — you must check for that too. Slot dynamic has no manager at all: you can call any method on it you like, and nobody checks until the delivery actually happens — if you ask a bag of nails to "turn the screw", it fails right there on the loading dock, at runtime, in front of a customer.dynamic switches off Dart's compile-time type checking for that variable entirely. The compiler will let you call absolutely any method on a dynamic value — typos included — and only discovers the mistake when that line actually executes.
The same thing as runnable Dart — dynamic crashes at runtime, Object is checked at compile time, Object? admits null (exact output shown in the comments, verified):
dynamic should be rare in application code. Prefer a specific type, Object (then cast with as after checking with is), or generics. Every dynamic you write is a promise to the compiler "don't check this for me" — and the compiler will hold you to it, even when you're wrong.Object/Object? keep compile-time safety (you must prove the real type before using subtype-only members); dynamic throws that safety away and defers every mistake to a runtime NoSuchMethodError.4. final vs const
final is like a nametag glued onto one specific minibus the moment you're handed it: that nametag can never be peeled off and stuck onto a different minibus — the name is locked to THAT vehicle forever. But nothing stops someone from getting inside and rearranging the seats (the minibus is a normal, mutable object; only which minibus the name points to is frozen). const is stricter: it's a minibus stamped out of a mould at the factory (compile time) with its seats welded in place — nobody can ever rearrange them. And if two people separately order "the const minibus with these exact seats", the factory hands them the very same physical minibus, not two identical-looking ones.final: the variable's reference can be set exactly once, but that happens at runtime (it can depend on a function call, user input, etc.), and if it refers to a mutable object, the object's contents can still change.
const: the value must be knowable at compile time — no function calls, no runtime data — and the whole value is deeply immutable: a const list, its elements, and anything they point to are frozen. Dart also canonicalizes const values: two const literals with identical contents are stored as the exact same object in memory.
The same thing as runnable Dart — final vs const, what each allows, and identical on const vs non-const literals (exact output shown in the comments, verified):
For the complete mutable-vs-immutable story (unmodifiable views and copies, const constructors, copyWith, shallow vs deep copy) see D22 · Mutable vs Immutable.
const (compile-time data) or wrap in List.unmodifiable(...) / UnmodifiableListView (runtime data that should stay frozen).identical(a, b) checks whether a and b are the literal same object in memory (same address), not just equal contents. For const [1, 2] written twice, Dart's compiler notices the two literals are equal and hands out one shared canonical object — proven at the end of this section by real Dart output.final = set once, at runtime, contents may still change. const = fixed at compile time, deeply frozen, and canonicalized so equal literals share one object.5. late: doing work later, and only once
late tells Dart: "don't compute this until something actually reads it — and once you have, remember the answer."late final x = expensiveWork(); means expensiveWork() does not run when the line executes — it runs the first time something reads x, and never again after that. This is also exactly how Dart handles top-level and static variables: they are lazily initialized on first use, not at program start, so an expensive top-level constant that's never used never costs you anything.
The same thing as runnable Dart — lazy initialization runs once on first read; reading a never-assigned late field throws (exact output shown in the comments, verified):
Input size → what's feasible: a late final costs one initializer call however many times you read it (1 read or 106 reads → 1 call), so make an expensive value late when it may never be needed.
late String name; with no initializer and something reads name before any code assigns it, Dart throws an error whose message reads LateInitializationError: Field 'name' has not been initialized. (the thrown object's class is LateError; for a late local variable the word is “Local” instead of “Field”, and if the compiler can see that nothing was assigned yet it refuses to compile the read at all). late is a promise to the compiler that you'll assign it before it's read; break that promise and you get a crash, not a silent null.late defers work to first-read and caches the result. It trades a compile-time safety net for a runtime promise — use it when you're certain the value will be set before it's needed (e.g. via initState-style setup).6. Scope: blocks, shadowing, top-level & static
{ }) is only heard inside that kitchen. If someone in the kitchen is also named "Sam", and there's a "Sam" in the living room, shouting "Sam!" in the kitchen only reaches the kitchen's Sam — the living-room Sam never even hears it. That's shadowing: an inner name with the same spelling as an outer one temporarily hides the outer one inside its own block, without changing it.Scope is the region of code where a name is visible. A variable declared inside { } (a block — the body of an if, a loop, a function) only exists inside that block. A top-level variable (declared outside any class or function, directly in a file) is visible everywhere in that file (and importable elsewhere), and is lazily initialized the first time it's touched, exactly like a late variable — static variables (one shared copy per class, rather than per object — classes are covered in a later lesson) behave the same way.
The same thing as runnable Dart — block scope, shadowing, and lazily initialized top-level variables (exact output shown in the comments, verified):
7. Naming conventions & privacy
- Variables, functions, parameters:
lowerCamelCase—userName,totalPrice. - Types (classes, enums, typedefs, type parameters, extensions):
UpperCamelCase—String,HttpClient,UserProfile. - Constants can be
lowerCamelCasetoo in modern Dart style (the oldSCREAMING_CAPSconvention was dropped) —const maxRetries = 3;. - A leading underscore (
_balance) makes a top-level declaration or class member private to its own library (in practice, its own.dartfile) — this IS enforced by the compiler, not just a style choice. Code in another file simply cannot see_balanceat all.
The same thing as runnable Dart — the naming conventions plus the compiler-enforced underscore privacy (exact output shown in the comments, verified):
8. Checking and converting types
is), and only then do you actually use it as one (as). Converting a hand-written note ("42") into an actual number you can do maths with is a deliberate step (int.parse), not something that happens automatically — and going the other way, turning a number back into text for display, is toString().value is Type— "does this value's runtime type match (or extend)Type?" Returnstrue/false, never throws.value as Type— "I assert this is aType; give it to me as that type." Throws aTypeErrorat runtime if you're wrong — alwaysis-check first (insideif (value is Type)Dart even treats the value as that type automatically) unless you're certain.int.parse('42')— converts text to anint, throws aFormatExceptionif the text isn't a valid integer.int.tryParse('42')returnsnullinstead of throwing — the safe choice when input might be bad. Both accept decimal digits (with optional+/-and surrounding whitespace) and also hexadecimal text written with a0xprefix, e.g.int.tryParse('0x1F')is31.42.toString()— converts a value to its text representation.value.runtimeType— tells you the concrete type at runtime; useful for debugging/printing, but a poor choice to branch program logic on (preferis/pattern matching —runtimeTypeoutput isn't guaranteed stable across compilation modes, e.g. web vs native, and comparing it as aStringis fragile).
The same thing as runnable Dart — is, as, int.parse/tryParse, toString and runtimeType (exact output shown in the comments, verified):
Input size → what's feasible: int.parse reads each character once: O(L) for an L-digit string, and an int holds at most 19 digits (−9223372036854775808 … 9223372036854775807) — longer text makes tryParse return null (use BigInt.parse beyond that).
int.parse directly on untrusted input (user typing, network data) without a try/catch or using int.tryParse — a single bad string crashes the call unless handled.Quiz
Interview questions
Cheat sheet
| Keyword | When fixed | Contents mutable? | Canonicalized? |
|---|---|---|---|
var | type inferred once at declaration | depends on the object | no |
final | reference set once, at runtime | yes, if the object is mutable | no |
const | value fixed at compile time | no — deeply frozen | yes — equal literals share one object |
late | deferred to first read (then cached) | n/a (about timing, not mutability) | no |
dynamic | never checked at compile time | n/a | no |