const Constructors & Compile-Time Constants
This is Part 4 of the Dart Internals & Performance series. In Part 3 we saw that allocation and GC, while cheap, aren't free. This part is about making them literally free for a huge class of objects — by building them at compile time instead of runtime.
const looks like a beginner topic ("it's just a stronger final, right?"). It isn't. const reaches all the way down into Part 1's snapshot and Part 3's GC, and it's the single most repeated performance tip in Flutter for a reason most developers can't fully explain. Let's fix that.
Assumes the snapshot/heap model from Part 1 and GC from Part 3. We're on Dart 3.12.
final vs const: a difference of time, not just mutability
Both final and const give you a variable you can't reassign. The difference is when the value is computed:
| | final | const |
| --- | --- | --- |
| Reassignable? | No | No |
| Value known... | at runtime (first access) | at compile time |
| Deeply immutable? | The reference is; the object may mutate internally | Fully, transitively immutable |
| Can use DateTime.now()? | ✅ Yes | ❌ No (not known at compile time) |
final now = DateTime.now(); // ✅ computed when this line runs
const pi = 3.14159; // ✅ baked in by the compiler
const bad = DateTime.now(); // ❌ compile error: not a constant expression
final list = const [1, 2, 3]; // a runtime variable pointing at a const list
The mental model:
finalmeans "assigned once, at runtime."constmeans "known to the compiler, frozen forever, before the program even starts."constis a much stronger promise — and the compiler rewards you for it.
What const actually does: build the object at compile time
A const constructor lets you construct an object (not just a number or string) entirely at compile time. The compiler evaluates it once, builds the instance, and bakes it into the snapshot's pre-initialized heap (remember from Part 1: the AOT snapshot ships a heap of already-built objects).
class Point {
final int x, y;
const Point(this.x, this.y); // const constructor
}
void main() {
const origin = Point(0, 0); // built at COMPILE time, not when main runs
print('${origin.x}, ${origin.y}');
}
The consequence is profound: const Point(0, 0) costs zero allocation at runtime. The object already exists in the snapshot. The running program just points at it. No new, no heap bump in Part 3's new space, no garbage to collect later. It's the closest Dart gets to a free object.
Requirements for a const constructor
To make a constructor const, the class must be deeply immutable:
- Every instance field is
final. - The constructor body is empty (initialization only via the initializer list / field initializers).
- It doesn't extend a class with a non-const generative constructor.
And to invoke it as const, every argument must itself be a constant expression.
class Circle {
final double radius;
const Circle(this.radius);
}
const r = 2.0;
const c1 = Circle(r); // ✅ r is const
final dynamicR = readRadius();
final c2 = Circle(dynamicR); // ⚠️ legal, but NOT const — built at runtime
// const c3 = Circle(dynamicR); // ❌ error: argument isn't a constant
Key: a
constconstructor enables compile-time construction; it doesn't force it.Circle(dynamicR)uses the same constructor but builds at runtime because its argument isn't constant. Theconstkeyword at the call site (or context) is what requests the compile-time version.
Canonicalization: identical constants are the same object
Here's the property that powers the Flutter optimization. When you create the same const value twice, Dart doesn't make two objects — it canonicalizes them to one shared instance.
const a = Point(1, 2);
const b = Point(1, 2);
print(identical(a, b)); // true — literally the SAME object in memory
Compare that to runtime construction:
final c = Point(1, 2); // (imagine Point had a non-const path)
final d = Point(1, 2);
print(identical(c, d)); // false — two separate allocations
Canonicalization means: for a given const expression, there is exactly one instance in the whole program, deduplicated by the compiler.
const ['a', 'b']written in fifty files is one list.const SizedBox(height: 8)used a thousand times is oneSizedBox.
This isn't just a memory saving (though it is — one object instead of thousands). It's the hook Flutter uses to skip work.
Why Flutter begs you to use const
Flutter's build() runs constantly (Part 3). When the framework rebuilds a widget, it compares the new widget to the old one to decide whether the underlying render object needs updating. Two things make const a superpower here:
1. const widgets are created once. A const widget in a build() method isn't re-allocated every frame — it's the same canonical instance every time. Zero allocation, zero GC pressure, every single rebuild.
@override
Widget build(BuildContext context) {
return Column(
children: [
const Text('Title'), // ✅ same instance every rebuild — never re-created
const SizedBox(height: 8), // ✅ one canonical SizedBox for the whole app
Text('Count: $count'), // changes each frame — NOT const (depends on state)
],
);
}
2. Flutter can short-circuit the rebuild. When the parent rebuilds, Flutter checks each child. If the new child widget is identical() to the old one — which a canonicalized const widget always is — Flutter knows nothing changed and can skip re-building that subtree entirely.
The payoff: marking a static subtree
constmeans Flutter (a) never re-allocates it and (b) can prune it from the rebuild walk because the instance is unchanged. On a complex screen, liberalconstmeasurably cuts both allocation churn and rebuild work. This is whyprefer_const_constructorsis a default lint and why the IDE nags you with the blue "Prefer const" hint.
// ❌ Rebuilt and re-allocated every frame, even though it never changes:
Icon(Icons.star, color: Colors.amber)
// ✅ One canonical instance; skipped on rebuild:
const Icon(Icons.star, color: Colors.amber)
const collections and nested const
Collections and nested objects can be const too — and const is transitive: a const collection's elements must themselves be constant.
const sizes = [8.0, 16.0, 24.0]; // const List
const config = {'retries': 3, 'timeout': 30}; // const Map
const themeColors = {Colors.red, Colors.blue}; // const Set
// Nested: the whole tree is built at compile time and canonicalized.
const card = CardData(
title: 'Hello',
points: [1, 2, 3], // inner list is const too
);
A neat trick: inside an already-const context, you don't repeat const — it propagates downward.
// One outer const; the inner EdgeInsets and TextStyle are implicitly const.
const padding = Padding(
padding: EdgeInsets.all(8), // implicitly const here
child: Text('Hi', style: TextStyle(fontSize: 14)), // implicitly const
);
Const in switch, defaults, and annotations
Because they're compile-time values, constants unlock things runtime values can't:
const small = 8.0;
const large = 24.0;
// Constants are required for switch case labels and enum-like dispatch.
String label(double size) => switch (size) {
small => 'small', // case values must be constant
large => 'large',
_ => 'custom',
};
// Default parameter values must be constant.
void box({double size = small}) {} // ✅ 'small' is const
// Annotations are constant expressions.
class Service {
@Deprecated('Use v2') // a const annotation
void oldMethod() {}
}
If you've ever hit "Case expressions must be constant" or "Default value must be constant," now you know why: those positions are evaluated by the compiler, so they demand compile-time values.
Gotchas worth internalizing
1. const is contagious in a good way — but check your fields. One non-final field, and the whole class can't have a const constructor.
class Bad {
int count = 0; // ❌ not final → can't be const
const Bad(); // compile error
}
2. Equality of const objects is identity-cheap. Because identical const objects are the same instance, identical() and even == short-circuit instantly. (Flutter relies on this.)
3. const requires known values — generics included. A type variable isn't always const-constructible; arguments must be constants at the call site.
4. Don't fake it with final. final x = const Foo() gives you a runtime variable pointing at a const object — fine — but final x = Foo() (no const) allocates at runtime. The const keyword has to be there to get the compile-time object.
5. const ≠ faster code, it means no code. The win isn't a faster constructor; it's eliminating the construction at runtime entirely, plus deduplication, plus letting Flutter skip rebuilds.
Practice Challenges
Challenge 1 — final or const? Which compile, and which don't?
final a = DateTime.now();
const b = DateTime.now();
const c = [1, 2, 3];
final d = const {'x': 1};
Show solution
a ✅ (runtime value, fine for final). b ❌ — DateTime.now() isn't a constant expression. c ✅ — list of constants. d ✅ — a final variable pointing at a const map. Only b fails.
Challenge 2 — Make it const-able. This class can't have a const constructor. Fix it.
class Vector {
double x, y;
Vector(this.x, this.y);
}
Show solution
Make the fields final and the constructor const:
class Vector {
final double x, y;
const Vector(this.x, this.y);
}
A const constructor requires every instance field to be final (deeply immutable).
Challenge 3 — Predict identity. What do these print?
const p = Point(1, 1);
const q = Point(1, 1);
final r = Point(1, 1); // assume a const constructor exists
print(identical(p, q));
print(identical(p, r));
Show solution
true, then false. p and q are both compile-time constants → canonicalized to one instance. r is built at runtime (no const at the call site), so it's a separate object, not identical to the canonical p.
Challenge 4 — Flutter rebuild. Why is const SizedBox(height: 8) better than SizedBox(height: 8) inside a frequently-rebuilt build()?
Show solution
The const version is a single canonical instance reused every rebuild — no re-allocation and no GC pressure — and because the new child is identical() to the old one, Flutter can skip re-building that subtree. The non-const version allocates a fresh SizedBox every frame and can't be pruned as trivially.
Challenge 5 — Where does the const object live? In a release (AOT) build, where does const Point(0, 0) physically come from at runtime, and what does that save?
Show solution
It's built at compile time and stored in the AOT snapshot's pre-initialized heap (Part 1). At runtime the program just references it — zero allocation, no new-space bump, and nothing for the GC to collect (Part 3).
Questions to test yourself
Q1 (basic). State the core difference between final and const.
Show answer
final is assigned once at runtime; const is a compile-time constant (known and built before the program runs) and is deeply, transitively immutable.
Q2 (basic). What are the requirements for a class to have a const constructor?
Show answer
All instance fields must be final, the constructor body must be empty (init via initializer list/field initializers), and it mustn't call a non-const superclass generative constructor. To invoke it as const, all arguments must be constant expressions.
Q3 (intermediate). What is canonicalization and why does it matter?
Show answer
For a given const expression, Dart creates exactly one shared instance and reuses it everywhere — identical const values are the same object (identical() is true). It saves memory (dedup) and, crucially, lets Flutter detect "unchanged" widgets by identity and skip rebuilding them.
Q4 (intermediate). Does a const constructor force compile-time construction? Explain with Foo(dynamicValue).
Show answer
No. A const constructor enables compile-time construction but only when invoked in a const context with constant arguments. Foo(dynamicValue) uses the same constructor but builds at runtime because the argument isn't constant. You need const at the call site (or an enclosing const context) and constant arguments to get the compile-time object.
Q5 (advanced). Explain the two distinct ways const improves Flutter performance.
Show answer
(1) No allocation: a const widget is a canonical instance built once (into the snapshot heap), so it's never re-allocated across rebuilds — zero GC churn. (2) Skipped rebuilds: because the rebuilt child is identical() to the previous one, Flutter can prune that subtree from the rebuild/diff walk entirely. Together: less allocation and less work per frame.
Q6 (advanced). Why must switch case labels and default parameter values be const?
Show answer
Both are resolved by the compiler, not at runtime: case labels are compiled into constant dispatch and must be known/comparable at compile time, and default values are baked into the method's signature. Non-constant expressions can't be evaluated then, so the compiler requires constant expressions in those positions.
Wrapping up
finalis "assigned once at runtime";constis "known and built at compile time," deeply immutable.- A const constructor (all fields
final, empty body) lets you build whole objects at compile time, baked into the snapshot heap — zero runtime allocation. - Identical constants are canonicalized to one shared instance (
identical()istrue), deduplicating memory. - Flutter exploits this twice: const widgets are never re-allocated, and their identity lets the framework skip rebuilding unchanged subtrees — hence
prefer_const_constructors. constpropagates through nested collections/objects and is required inswitchcases, default values, and annotations.
In Part 5 we cross the boundary out of the managed Dart world entirely and call native machine code directly: Dart FFI — invoking C functions, mapping types and structs, and managing memory the GC won't touch for you.