Dart / Contracts: patterns, generic APIs, and asynchronous results / Lesson 8
Model alternatives with sealed outcomes and enums
Represent one valid outcome at a time and keep internal exhaustive handling separate from external-code compatibility.
On this page
Lesson goals
- Choose different payload shapes for different outcomes instead of contradictory nullable fields.
- Use stable enum codes and review the consequences of adding alternatives.
Make invalid combinations harder to construct
A response with ok, count, jobId, and error fields can admit success with an error or pending work without a job identity. A sealed hierarchy gives each alternative the data it requires. Accepted can carry a positive count, Rejected a reason, and Pending a job reference. The hierarchy alone does not validate string formats or numeric ranges; each constructor or adapter must establish those rules. Start by listing the permitted business states rather than copying a transport payload with every field nullable. Types are useful when they preserve meaning at a boundary.
Use the library boundary to control direct alternatives
Dart sealed classes restrict direct extension or implementation to the same library and support exhaustiveness analysis. They are implicitly abstract, so callers use concrete alternatives. The example uses final subclasses and object patterns in a switch expression to extract each payload. All definitions are in one illustrative library and require Dart 3 language support. The classifier has no wildcard because handling every known alternative is valuable here. Adding a new alternative can expose source handlers that need revision, which is useful evidence rather than a reason to hide all future cases behind a generic fallback.
sealed class BatchOutcome {}
final class Accepted extends BatchOutcome {
final int count;
Accepted(this.count);
}
final class Rejected extends BatchOutcome {
final String reason;
Rejected(this.reason);
}
String describe(BatchOutcome value) => switch (value) {
Accepted(count: final count) => 'accepted:$count',
Rejected(reason: final reason) => 'rejected:$reason',
};
void main() {
print(describe(Accepted(2)));
print(describe(Rejected('invalid-input')));
}
accepted:2
rejected:invalid-input
Expected output is accepted:2 then rejected:invalid-input. This small model omits constructor validation and pending work deliberately; its declared alternatives are complete only for this example. No Dart compilation occurred.
Use an enhanced enum for uniform finite choices
An enum defines a fixed set of instances of one type. Enhanced enums can attach final fields and behavior to those instances, with constructor rules appropriate to enum constants. This fits uniform choices such as processing modes, but is less suitable when each alternative needs a fundamentally different payload. Never persist index as an unexplained business code: reordering constants changes that position. Prefer an explicit stable code, and keep display labels independent so translation or editorial changes do not break stored data. An enum names states; it does not automatically enforce legal transitions between them.
Translate unknown external codes intentionally
The parser below uses a loop to return null for an unsupported mode rather than throwing from an exact-name lookup. The code is separate from the source enum name and can remain stable while internal naming changes. Returning null is one policy; another application may preserve the unknown raw code in a dedicated boundary result. Do not silently choose a powerful mode as a default when the sender used an unsupported value. Validation should make the compatibility decision visible and avoid pretending that an unknown code was explicitly selected by the caller.
enum ProcessingMode {
preview('preview'), commit('commit');
const ProcessingMode(this.code);
final String code;
}
ProcessingMode? parseMode(String code) {
for (final mode in ProcessingMode.values) {
if (mode.code == code) return mode;
}
return null;
}
void main() {
print(parseMode('preview')?.name);
print(parseMode('future-mode')?.code ?? 'unsupported');
}
preview
unsupported
Expected output is preview then unsupported. The program uses enhanced-enum syntax and a nullable lookup; it does not execute any processing mode or external operation. It was not compiled.
Separate source exhaustiveness from wire compatibility
Compiler knowledge of local subclasses cannot validate incoming bytes from a newer remote producer. A new enum value or sealed alternative can require consumer changes, while serialized names and fields create a separate public contract. Review mixed-version behavior if applications release independently. A broad wildcard can be correct at an untrusted boundary but can hide missing internal handling if used indiscriminately. Define which alternatives are product outcomes, which are integration failures, and how long older clients remain supported. Do not claim binary or serialization compatibility merely because one local switch compiles.
Common errors and fixes
A successful outcome carries neither required data nor a reason.
- Why it happens
- Independent nullable fields admitted contradictory combinations.
- How to fix it
- Give each alternative its required payload and validate that payload.
- Prevent it next time
- Review all constructible states rather than intended examples alone.
Stored enum indices change meaning after reordering.
- Why it happens
- Declaration order was treated as a durable external identifier.
- How to fix it
- Introduce explicit stable codes and migrate stored representations deliberately.
- Prevent it next time
- Separate identity, display text, and presentation order.
A wildcard silently absorbs a newly added internal outcome.
- Why it happens
- Exhaustiveness checking was bypassed without a compatibility reason.
- How to fix it
- Handle internal alternatives explicitly where complete handling is required.
- Prevent it next time
- Review outcome consumers whenever the hierarchy evolves.
Check your understanding
1. Is a sealed hierarchy enough to guarantee Accepted always carries a positive count?
Show worked solution
No. Sealing restricts alternatives and helps exhaustive handling, but the example constructor accepts any int. Add a validated construction boundary or a constructor guard if positivity is an invariant. Keep expected external rejection separate from programmer misuse, and test zero and negatives as well as ordinary positive values. Type shape and payload validity remain distinct claims.
2. Why should unknown processing codes not automatically fall back to commit?
Show worked solution
That would assign a consequential action the caller did not successfully specify and could hide a version mismatch or malformed input. Reject unsupported codes or preserve them for an explicit compatibility path. A preview-only default may also be inappropriate if it implies a completed action. Choose the policy from the product contract, not from the convenience of eliminating null.
Key takeaways
- Sealed alternatives and enums describe different kinds of finite choice.
- Stable external codes and validated payloads need explicit contracts.