| |

Dart 19 🎯 Const Constructors and Immutable Class Instances

A const constructor is what turns a class from a runtime type into a compile-time value. When a class has a const constructor and all its fields are final, instances can be created at compile time and stored in the program’s read-only data. The compiler canonicalizes those instances: two const Point(1, 2) expressions anywhere in the program refer to the same object in memory. This is not a minor optimization. It is the mechanism that lets immutable value objects be used in const lists, const maps, switch patterns, default parameter values, and annotations, all of which require compile-time constants.

Immutability is the precondition. A class whose fields can change cannot be a compile-time constant, because the compiler cannot freeze a value that might later be modified. The final keyword on every field, combined with a const constructor whose initializer list contains only constant expressions, is the recipe for an immutable value class. The result is an object that behaves like a primitive: it can be shared freely, compared by value, and used wherever a literal would be accepted.

This chapter covers the const constructor, the final field requirement, canonicalization, deep versus shallow immutability, the difference between const and final variables, immutable collections, the patterns for value objects, and the mistakes that break the const contract.

Key point: A const constructor requires all fields to be final and the initializer list to contain only constant expressions. const instances are canonicalized: identical const expressions refer to the same object. A const constructor makes the class usable in const contexts — const lists, maps, default values, and patterns. Immutability is shallow: a final field holding a mutable list can still have its contents changed.


Why const constructors and immutability matter

The compile-time constant problem. Some language features require values that are known at compile time: const variables, default parameter values, annotation arguments, and case patterns in a switch. An instance of a user-defined class can be such a value only if the class has a const constructor. Without it, the class cannot participate in these features.

The canonicalization problem. Two const Point(1, 2) expressions should represent the same value. Canonicalization makes them the same object, so identical(a, b) is true and a == b follows. This is what allows const instances to be used as map keys and set elements without surprises.

The immutability problem. A const instance cannot change. If a field were mutable, the compiler could not guarantee the value stays constant, and the canonicalization would be unsound. The final requirement on every field enforces the guarantee.

The performance problem. A const instance is created once and shared. A runtime instance is created each time the constructor is called. For value objects that appear frequently — colors, sizes, configurations — the const version avoids allocation and reduces memory.

The sharing problem. Immutable objects can be shared safely. Two parts of the program can hold references to the same const instance without worrying that one will change it. This is the foundation of value semantics: the object is identified by its value, not by its identity.


a. The const constructor

A const constructor is declared with the const keyword before the constructor name. It requires that all fields are final and that the initializer list contains only constant expressions.

class Point {
  final double x;
  final double y;

  const Point(this.x, this.y);
}

The const keyword on the constructor marks it as a constant constructor. The compiler verifies that the class’s fields are all final and that the initializer list does not call any non-constant function.

A class can have both a const constructor and a non-const constructor:

class Point {
  final double x;
  final double y;

  const Point(this.x, this.y);
  Point.runtime(this.x, this.y);
}

The const constructor is used with the const keyword at the call site, and the non-const constructor is used without it. A class with a const constructor can still be instantiated at runtime by calling the constructor without const.


b. Creating const instances

A const instance is created with the const keyword before the constructor call, or in a const context where the keyword is implied.

const origin = Point(0, 0);
const unit = Point(1, 1);

Inside a const context — a const list, a const map, a const variable’s initializer, a default parameter value — the const keyword is implied for the inner expressions:

const points = [
  Point(0, 0),  // const implied
  Point(1, 1),  // const implied
];

The list is const, so its elements must be const, and the compiler infers the const for the Point expressions. Writing const Point(0, 0) inside the list is allowed but redundant.

A const instance can also be created with the const keyword outside a const context:

var p = const Point(0, 0);

The const keyword makes the instance a compile-time constant, and the variable p is a runtime variable that holds the constant.


c. Canonicalization

The compiler canonicalizes const instances. Two const expressions with the same values refer to the same object.

const a = Point(1, 2);
const b = Point(1, 2);

print(identical(a, b));  // true
print(a == b);           // true

The identical function checks whether two references point to the same object. For const instances, identical expressions are canonicalized to the same object, so identical returns true.

A runtime instance is not canonicalized:

var c = Point(1, 2);  // runtime instance
print(identical(a, c));  // false
print(a == c);           // true if == is overridden

The runtime instance is a separate object. The == comparison may still return true if the class overrides == to compare by value, but identical returns false.

Canonicalization applies to const instances of any class, not just user-defined ones. const [1, 2] and const [1, 2] refer to the same list.


d. Deep versus shallow immutability

A const constructor makes the fields final, but final is shallow. A final field holding a mutable list can still have its contents changed.

class Config {
  final List<String> hosts;

  const Config(this.hosts);
}

const config = Config(['a', 'b']);
// config.hosts.add('c');  // error: cannot modify a const list

The const constructor requires the list to be const as well, because the initializer list must contain constant expressions. A const list is deeply immutable, and its contents cannot be changed.

But a class with a final field that holds a non-const list is only shallowly immutable:

class Config {
  final List<String> hosts;

  Config(this.hosts);
}

var config = Config(['a', 'b']);
config.hosts.add('c');  // allowed: the list is not const

The hosts field cannot be reassigned, but the list it points to can be modified. The class is immutable at the field level but not at the content level.

Deep immutability requires that every field holds an immutable value. This means using const lists, const maps, or the unmodifiable wrappers.


e. const versus final variables

The two keywords are related but distinct. A const variable is a compile-time constant. A final variable is a runtime constant that is assigned once.

Aspectconstfinal
When assignedCompile timeRuntime
Can be a constant expressionYesNo
Can be used in const contextYesNo
Value known at compile timeYesNo
Exampleconst x = 5;final x = DateTime.now();
const a = 5;                    // compile-time constant
final b = DateTime.now();       // runtime constant

A const variable is implicitly final. A final variable is not implicitly const.

A const constructor requires the instance to be created in a const context, which means all the arguments must be compile-time constants. A final variable can hold a const instance or a runtime instance.


f. Immutable collections

The dart:collection library provides unmodifiable wrappers for lists, sets, and maps.

import 'dart:collection';

final list = List<int>.unmodifiable([1, 2, 3]);
final set = Set<int>.unmodifiable({1, 2, 3});
final map = Map<String, int>.unmodifiable({'a': 1});

The wrapper prevents modification, but the underlying collection can still be changed by whoever holds a reference to it. The wrapper is a view, not a copy.

For deep immutability, the const collections are the alternative:

const list = [1, 2, 3];       // deeply immutable
const set = {1, 2, 3};        // deeply immutable
const map = {'a': 1, 'b': 2}; // deeply immutable

The const collections are canonicalized and their contents cannot be changed. They are the correct choice for immutable value objects.

A const constructor can take a const collection as a field:

class Config {
  final List<String> hosts;

  const Config(this.hosts);
}

const config = Config(['a', 'b']);

The ['a', 'b'] is a const list because the surrounding context is const. The config instance holds a deeply immutable list.


Complete Example Session

// ============================================
// PART 1: CONST CONSTRUCTOR
// ============================================
class Point {
  final double x;
  final double y;

  const Point(this.x, this.y);
}

void main() {
  const origin = Point(0, 0);
  print('${origin.x}, ${origin.y}');  // 0.0, 0.0
}
// ============================================
// PART 2: CONST IMPLIED IN A CONST CONTEXT
// ============================================
class Point {
  final double x;
  final double y;
  const Point(this.x, this.y);
}

void main() {
  const points = [
    Point(0, 0),
    Point(1, 1),
    Point(2, 2),
  ];
  print(points.length);  // 3
}
// ============================================
// PART 3: CANONICALIZATION
// ============================================
class Point {
  final double x;
  final double y;
  const Point(this.x, this.y);
}

void main() {
  const a = Point(1, 2);
  const b = Point(1, 2);
  print(identical(a, b));  // true
}
// ============================================
// PART 4: RUNTIME VS CONST INSTANCE
// ============================================
class Point {
  final double x;
  final double y;
  const Point(this.x, this.y);

  @override
  bool operator ==(Object other) =>
      other is Point && other.x == x && other.y == y;

  @override
  int get hashCode => Object.hash(x, y);
}

void main() {
  const a = Point(1, 2);
  var b = Point(1, 2);
  print(identical(a, b));  // false
  print(a == b);           // true
}
// ============================================
// PART 5: CONST AS DEFAULT PARAMETER
// ============================================
class Point {
  final double x;
  final double y;
  const Point(this.x, this.y);
}

void moveTo({Point target = const Point(0, 0)}) {
  print('Moving to ${target.x}, ${target.y}');
}
// ============================================
// PART 6: CONST IN A SWITCH PATTERN
// ============================================
class Direction {
  final int dx;
  final int dy;
  const Direction(this.dx, this.dy);
}

const north = Direction(0, -1);
const south = Direction(0, 1);

void describe(Direction d) {
  switch (d) {
    case north:
      print('Going north');
    case south:
      print('Going south');
    default:
      print('Going somewhere');
  }
}
// ============================================
// PART 7: SHALLOW IMMUTABILITY
// ============================================
class Config {
  final List<String> hosts;
  Config(this.hosts);
}

void main() {
  var config = Config(['a', 'b']);
  config.hosts.add('c');  // allowed
  print(config.hosts);    // [a, b, c]
}
// ============================================
// PART 8: DEEP IMMUTABILITY WITH CONST
// ============================================
class Config {
  final List<String> hosts;
  const Config(this.hosts);
}

void main() {
  const config = Config(['a', 'b']);
  // config.hosts.add('c');  // error: unmodifiable
}
// ============================================
// PART 9: UNMODIFIABLE WRAPPER
// ============================================
import 'dart:collection';

void main() {
  final list = List<int>.unmodifiable([1, 2, 3]);
  // list.add(4);  // error: unmodifiable
}
// ============================================
// PART 10: CONST VS FINAL
// ============================================
void main() {
  const a = 5;              // compile-time constant
  final b = DateTime.now(); // runtime constant
  print('$a, $b');
}

These ten parts cover the const constructor, const implied in a const context, canonicalization, runtime versus const instances, const as a default parameter, const in a switch pattern, shallow immutability, deep immutability with const, the unmodifiable wrapper, and const versus final.


Quick Reference

Const Constructor

RequirementDetail
All fields finalYes
Constant initializer listYes
No body statementsYes
Const superclassIf extending

Const vs Final

Aspectconstfinal
When assignedCompile timeRuntime
Constant expressionYesNo
Usable in const contextYesNo
Implicitlyfinal—

Canonicalization

Expressionidentical
const Point(1, 2) vs const Point(1, 2)true
const Point(1, 2) vs Point(1, 2)false
const [1, 2] vs const [1, 2]true

Immutability Levels

LevelDescription
ShallowFields are final; contents may be mutable
DeepFields are final and hold immutable values
ConstCompile-time constant, canonicalized

Const Collections

TypeExample
Listconst [1, 2, 3]
Setconst {1, 2, 3}
Mapconst {'a': 1}

Unmodifiable Wrappers

ConstructorPurpose
List.unmodifiableRead-only list view
Set.unmodifiableRead-only set view
Map.unmodifiableRead-only map view

Best Practices

✅ Do This:

// Use const constructors for value classes
class Point {
  final double x;
  final double y;
  const Point(this.x, this.y);
}

// Use const for instances that appear frequently
const origin = Point(0, 0);

// Use const collections for deep immutability
const config = Config(['a', 'b']);

// Use const for default parameter values
void moveTo({Point target = const Point(0, 0)}) { }

// Use const in switch patterns
switch (direction) {
  case north: ...
}

❌ Don’t Do This:

// Non-final field in a const constructor
class Point {
  double x;  // ❌
  const Point(this.x);
}

// Non-constant initializer in a const constructor
const Point(this.x) : y = compute();  // ❌

// Assume final is deeply immutable
class Config {
  final List<String> hosts;  // ⚠️ contents mutable
}

// Use a runtime instance where const is required
const a = Point(DateTime.now().year, 0);  // ❌

Common Pitfalls

PitfallWhy It HappensFix
const constructor rejectedA field is not finalMake all fields final
Non-constant initializerFunction call in the listUse a constant expression
Shallow immutability surprisefinal field holds a mutable listUse const or unmodifiable
identical returns falseRuntime instanceUse const for canonicalization
Const in a non-const contextMissing const keywordAdd const or use a const context
Mutable collection in a const classNon-const listUse const list

Real-World Examples

1. Value Class

class Point {
  final double x;
  final double y;
  const Point(this.x, this.y);
}

2. Color

class Color {
  final int r, g, b;
  const Color(this.r, this.g, this.b);
}

const red = Color(255, 0, 0);

3. Configuration

class Config {
  final String host;
  final int port;
  const Config(this.host, this.port);
}

const defaultConfig = Config('localhost', 8080);

4. Default Parameter

void connect({Config config = const Config('localhost', 80)}) { }

5. Switch Pattern

const north = Direction(0, -1);
switch (d) { case north: ... }

6. Const List

const colors = [Color(255, 0, 0), Color(0, 255, 0)];

7. Const Map

const sizes = {'small': 1, 'large': 2};

8. Immutable Value Object

class Money {
  final int cents;
  final String currency;
  const Money(this.cents, this.currency);
}

9. Unmodifiable List

final list = List<int>.unmodifiable([1, 2, 3]);

10. Canonicalization

const a = Point(1, 2);
const b = Point(1, 2);
print(identical(a, b));  // true

Visual

Const Constructor Requirements

┌──────────────────────────────────────────────────────────────┐
│  class Point {                                               │
│    final double x;    ← all fields final                     │
│    final double y;                                           │
│                                                              │
│    const Point(this.x, this.y);  ← const keyword             │
│    └── initializer list: constant expressions only           │
│  }                                                           │
│                                                              │
│  const origin = Point(0, 0);  ← const context                │
└──────────────────────────────────────────────────────────────┘

Canonicalization

┌──────────────────────────────────────────────────────────────┐
│  const a = Point(1, 2);                                      │
│  const b = Point(1, 2);                                      │
│                                                              │
│  ┌─────────────────────┐                                     │
│  │  Point(1, 2)        │  ← one canonical instance           │
│  └─────────────────────┘                                     │
│       ▲         ▲                                            │
│       │         │                                            │
│    a ─┘      b ─┘                                            │
│                                                              │
│  identical(a, b) is true.                                    │
└──────────────────────────────────────────────────────────────┘

Shallow vs Deep Immutability

┌──────────────────────────────────────────────────────────────┐
│  SHALLOW (final field, mutable list):                        │
│  class Config {                                              │
│    final List<String> hosts;                                 │
│  }                                                           │
│  └── config.hosts.add('c') is allowed                        │
│                                                              │
│  DEEP (const field, const list):                             │
│  class Config {                                              │
│    final List<String> hosts;                                 │
│    const Config(this.hosts);                                 │
│  }                                                           │
│  const config = Config(['a', 'b']);                          │
│  └── config.hosts.add('c') is an error                       │
└──────────────────────────────────────────────────────────────┘

Const vs Final Variables

┌──────────────────────────────────────────────────────────────┐
│  const a = 5;               // compile-time constant         │
│  └── usable in const contexts                                │
│                                                              │
│  final b = DateTime.now();  // runtime constant              │
│  └── not usable in const contexts                            │
│                                                              │
│  const c = Point(1, 2);     // const instance                │
│  final d = Point(1, 2);     // runtime instance              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Const constructorconst Point(this.x, this.y)
RequirementAll fields final, constant initializer list
Const instanceconst Point(1, 2)
CanonicalizationIdentical const expressions share an object
Const vs finalCompile time vs runtime
Shallow immutabilityFinal fields; contents may be mutable
Deep immutabilityConst fields and const collections
Unmodifiable wrapperList.unmodifiable, Set.unmodifiable
Const in default paramsconst Point(0, 0)
Const in patternscase north:

Key takeaways:

  • A const constructor makes the class usable in compile-time contexts. const lists, maps, default parameter values, and switch patterns all require instances that are known at compile time.
  • All fields must be final. The compiler verifies this. A mutable field would make the const guarantee unsound.
  • The initializer list must contain constant expressions. No function calls, no runtime values. The compiler evaluates the expressions at compile time.
  • const instances are canonicalized. Two identical const expressions refer to the same object, so identical returns true. This is what makes const instances safe as map keys and set elements.
  • final is shallow immutability. A final field holding a mutable list can have its contents changed. Deep immutability requires const collections or unmodifiable wrappers.
  • const and final are different. const is a compile-time constant, final is a runtime constant assigned once. A const variable is implicitly final.
  • Immutable value classes are the right choice for value semantics. Objects that are compared by value, used as keys, or shared across the program benefit from the const constructor and the canonicalization it enables.

Remember: The const constructor is what turns a class into a compile-time value. It requires every field to be final and every initializer to be a constant expression, and in return it allows the class to be used wherever a literal would be accepted. The canonicalization means identical const expressions share one object, which is what makes value objects behave like primitives. The immutability is the precondition: a const instance cannot change, and a final field alone does not guarantee that the contents of a collection are immutable. For deep immutability, use const collections. For value classes — points, colors, configurations, money — the const constructor is the idiomatic pattern in Dart.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!