Course outline

Dart / Foundations: values, absence, collections, and valid objects / Lesson 5

Build valid objects and understand library privacy

Use fields, initializing formals, named and const constructors, getters, and explicit state transitions in Dart.

About 5 min readContent reviewed
On this page

Lesson goals

  • Place required state and validation at construction rather than relying on remembered setup.
  • Distinguish library-private names, final fields, object identity, and value equality.

Make required state available when construction finishes

A class groups data and operations, and a constructor defines how an instance becomes ready. Initializing formals such as this.id assign constructor input to fields concisely. An initializer list can establish fields before the constructor body performs validation or other setup. Prefer an object whose public operations can rely on its invariant immediately after construction. A public default value that temporarily violates the domain can force every caller to remember a repair step. Static field types do not validate business ranges, so establish those restrictions explicitly rather than assuming final or a constructor name provides them.

Expose a transition that preserves the rule

The Batch example rejects a negative initial count and exposes remaining through a getter. consume returns false instead of making the count negative. The underscore on _remaining makes the name private to its Dart library, not exclusively to the class. Code in the same library can still access it, so library organization and review are part of the intended boundary. This is one local in-memory model, not durable multi-user allocation. It does not provide cross-isolate synchronization, persistence, request deduplication, or protection across separate application instances.

dart A locally owned count transition
class Batch {
  final String id;
  int _remaining;
  Batch(this.id, int count) : _remaining = count {
    if (count < 0) throw ArgumentError.value(count, 'count');
  }
  int get remaining => _remaining;
  bool consume() {
    if (_remaining == 0) return false;
    _remaining -= 1;
    return true;
  }
}
void main() {
  final batch = Batch('B-9', 1);
  print(batch.consume());
  print(batch.consume());
  print(batch.remaining);
}
Expected output
true
false
0

Expected output is true, false, 0. The public operations preserve nonnegative count in this sequential example. Library peers can access underscore names, and no runtime or concurrency test was performed.

Choose a constructor for the promise it makes

Named constructors make alternative creation paths explicit; they are not method overloads selected only by different parameter types. A const constructor can create compile-time constant instances when its requirements and invocation context permit it, while the same constructor may also be used in a non-constant context. A factory constructor can return an existing instance or another suitable implementation rather than always allocating a new object. These choices affect identity and expectations, so document meaningful caching or validation behavior. Avoid constructor variants that silently disagree about the invariant the public methods assume.

Identity and value equality require different reasoning

identical asks whether two references identify the same object. Ordinary class equality defaults to identity unless an implementation defines another contract. Constant canonicalization can cause equivalent constant constructions to share identity; it is not proof that every pair of separately created domain records compares by value. If overriding ==, keep hashCode consistent and avoid mutable equality-defining fields used as hash keys. The sample demonstrates a const value and a named fallback constructor only. It does not implement a complete validated identifier or promise deep immutability for arbitrary nested fields.

dart Constant identity and a named creation path
class SampleLabel {
  final String id;
  const SampleLabel(this.id);
  SampleLabel.unknown() : id = 'unknown';
  String get display => id.toUpperCase();
}
void main() {
  const first = SampleLabel('lab');
  const second = SampleLabel('lab');
  print(identical(first, second));
  print(first.display);
  print(SampleLabel.unknown().id);
}
Expected output
true
LAB
unknown

Expected output is true, LAB, unknown. The two equivalent constant instances are canonicalized. The source was not compiled, and the unknown label is a teaching fallback rather than a valid business identifier.

Treat a class surface as a substitutable contract

Dart classes define implicit interfaces from their public instance members. Implementing that interface does not inherit the original implementation or private state. Inheritance, interface implementation, and mixin reuse therefore solve different problems and have modifier-specific restrictions explored later. Prefer composition when an object uses a capability rather than genuinely substituting for another type. Keep getters unsurprising: a simple-looking property that performs hidden I/O or expensive mutation violates many callersโ€™ expectations. Document effects and ownership at the API boundary rather than relying on object-oriented syntax to communicate them automatically.

Common errors and fixes

An underscore field is assumed inaccessible to every other class.

Why it happens
Dart library privacy was mistaken for class-only privacy.
How to fix it
Review the actual library boundary and avoid unauthorized peer mutation by design.
Prevent it next time
Document ownership within a library as well as its public surface.

One named constructor creates invalid state accepted by public methods.

Why it happens
Validation was attached to only one construction path.
How to fix it
Make every permitted creation path establish the shared invariant.
Prevent it next time
Test each constructor against the same domain restrictions.

A mutated key cannot be found reliably in a hash collection.

Why it happens
Equality-defining state changed after insertion.
How to fix it
Use stable value keys and consistent ==/hashCode behavior.
Prevent it next time
Review equality and mutation together before using a model as a key.

Check your understanding

1. Does a final field holding a mutable list make a class deeply immutable?

Show worked solution

No. The field reference cannot be replaced after initialization, but the list contents and mutable element objects can still change through aliases. Choose an appropriate unmodifiable snapshot and immutable element model or explicit ownership. A const constructor has separate requirements and does not retroactively freeze an arbitrary final object graph.

2. Why should an API document whether a factory returns cached instances?

Show worked solution

Callers may otherwise assume independent state or use identity for bookkeeping. Reusing an instance can expose shared mutation or lifecycle interactions unless the type is safely value-like. Document the contract, avoid relying on allocation as an undocumented signal, and test the behavior that callers are actually permitted to observe.

Key takeaways

  • Construction paths and state transitions must establish the same invariant.
  • Library privacy, final references, constant objects, and value equality are distinct mechanisms.

Official references