Dart 3 Sealed Classes: Replacing built_union Code Generation
Context
We eliminated 2 git-forked dependencies and removed 3,686 lines of generated code by migrating 9 union types from built_union code generation to native Dart 3 sealed classes. This migration enabled compile-time exhaustive checking, eliminated maintenance burden from git forks, and reduced build complexity.
The Problem
The application used built_union package to represent discriminated unions (sum types) for 9 critical types including ContentBitUnion (41 variants), SectionUnion (56 variants), and 7 others. This approach had significant drawbacks:
Git Fork Dependencies
dependencies:
built_union:
git:
url: https://github.com/bt-amal/built_union.dart
path: built_union
dev_dependencies:
built_union_generator:
git:
url: https://github.com/ZGTR/built_union.dart
path: built_union_generator
ref: patch-1 # Fork with bug fix
Problems:
- Maintained custom forks (security risk, update burden)
- ZGTR fork had
isEnumClassbug requiringpatch-1branch - No official pub.dev releases, pinned to git commits
- Breaking changes could occur without notice
Generated Code Overhead
8 .g.dart files totaling 3,686 lines:
| Union Type | Variants | Generated Lines | |------------|----------|-----------------| | ContentBitUnion | 41 | 1,261 | | SectionUnion | 56 | 913 | | ContentBitsUnion | 8 | 247 | | SubSectionUnion | 19 | 412 | | OnBoardingScreenUnion | 10 | 298 | | FloatingActionButtonUnion | 2 | 156 | | SpecialByteScreenUnion | 3 | 189 | | ContentBitsResultUnion | 7 | 210 |
Generated code included:
- Enum type definitions (
_$ContentBitTypewith one value per variant) .match<T>()method with 41-56 callback parameters.isXxxgetters for each variant (runtime type checking)StructuredSerializerfor JSON round-trip conversion- Equality operators and hashCode overrides
Runtime-Only Type Checking
// built_union approach (runtime checking)
if (contentBit.isSelectText) {
final model = contentBit.asSelectText;
// ... handle SelectTextQuestionModel
}
// No compile-time guarantee that all cases are handled
Problem: Compiler couldn't verify exhaustiveness, missing cases found at runtime.
ASCII Diagram: Before State
┌──────────────────────────────────────────────────────────────┐
│ @BuiltUnion() │
│ abstract class ContentBitUnion │
│ │
│ ContentBitUnion.selectText(SelectQuestionModel m) │
│ ContentBitUnion.bubblePop(BubblePopQuestionModel m) │
│ ... 39 more constructor variants │
└────────────────────────┬─────────────────────────────────────┘
│
│ build_runner generates ▼
│
┌────────────────────────▼─────────────────────────────────────┐
│ content_bit_union.g.dart (1,261 lines) │
│ │
│ enum _$ContentBitType { │
│ selectText, bubblePop, fillInBlanks, ... (41 values) │
│ } │
│ │
│ class _$ContentBitUnion { │
│ final _$ContentBitType type; │
│ final Object? model; │
│ │
│ T match<T>({ │
│ required T Function(SelectQuestionModel) selectText, │
│ required T Function(BubblePopModel) bubblePop, │
│ ... 39 more callback parameters │
│ }) { /* 100+ lines of switch logic */ } │
│ │
│ bool get isSelectText => type == _$ContentBitType.select │
│Text; bool get isBubblePop => type == _$ContentBitType.bubblePop; │
│ ... 39 more getters │
│ │
│ StructuredSerializer<ContentBitUnion> get serializer... │
│ ... (200 lines of serialization logic) │
│ } │
└──────────────────────────────────────────────────────────────┘
Build Pipeline:
┌─────────┐ ┌──────────────┐ ┌─────────┐
│ Annotate│ ─▶ │ build_runner │ ─▶ │Generate │
│ @Built │ │ + built_union│ │ .g.dart│
│ Union() │ │ _generator │ │ files │
└─────────┘ └──────────────┘ └─────────┘
▲
│ Requires git fork dependencies
The Solution
Dart 3.0 introduced sealed classes, enabling first-class discriminated unions with compile-time exhaustive checking. We migrated all 9 union types to this native language feature.
Migration Pattern
Before (built_union with code generation):
@BuiltUnion()
abstract class SpecialByteScreenUnion extends _$SpecialByteScreenUnion {
SpecialByteScreenUnion.unknown() : super.unknown();
SpecialByteScreenUnion.winner(SpecialByteScreenWinnerModel m)
: super.winner(m);
SpecialByteScreenUnion.battleStart(SpecialByteScreenBattleStartModel m)
: super.battleStart(m);
}
// Generated: special_byte_screen_union.g.dart (189 lines)
After (Dart 3 sealed class, hand-written):
sealed class SpecialByteScreenUnion {
const SpecialByteScreenUnion();
String get unionTypeName;
Object? get innerModel;
factory SpecialByteScreenUnion.unknown() =
SpecialByteScreenUnionUnknown;
factory SpecialByteScreenUnion.winner(SpecialByteScreenWinnerModel m) =
SpecialByteScreenUnionWinner;
factory SpecialByteScreenUnion.battleStart(
SpecialByteScreenBattleStartModel m
) = SpecialByteScreenUnionBattleStart;
// Backwards-compatible match() for existing code
T match<T>({
required T Function() unknown,
required T Function(SpecialByteScreenWinnerModel) winner,
required T Function(SpecialByteScreenBattleStartModel) battleStart,
}) {
return switch (this) {
SpecialByteScreenUnionUnknown() => unknown(),
SpecialByteScreenUnionWinner(:final model) => winner(model),
SpecialByteScreenUnionBattleStart(:final model) => battleStart(model),
};
}
}
// Individual case classes
class SpecialByteScreenUnionUnknown extends SpecialByteScreenUnion {
const SpecialByteScreenUnionUnknown();
@override String get unionTypeName => 'unknown';
@override Object? get innerModel => null;
}
class SpecialByteScreenUnionWinner extends SpecialByteScreenUnion {
final SpecialByteScreenWinnerModel model;
const SpecialByteScreenUnionWinner(this.model);
@override String get unionTypeName => 'winner';
@override Object? get innerModel => model;
}
class SpecialByteScreenUnionBattleStart extends SpecialByteScreenUnion {
final SpecialByteScreenBattleStartModel model;
const SpecialByteScreenUnionBattleStart(this.model);
@override String get unionTypeName => 'battle_start';
@override Object? get innerModel => model;
}
Result: 3 case classes (~80 lines hand-written) vs 189 lines generated.
Compile-Time Exhaustive Checking
Native switch expressions (preferred, Dart 3 feature):
Widget build(BuildContext context) {
return switch (specialByteScreen) {
SpecialByteScreenUnionUnknown() => SizedBox.shrink(),
SpecialByteScreenUnionWinner(:final model) => WinnerScreen(model),
SpecialByteScreenUnionBattleStart(:final model) => BattleStartScreen(model),
};
// ✅ Compiler error if case missing: "The type 'SpecialByteScreenUnion'
// is not exhaustively matched by the switch cases."
}
Backwards-compatible match() (for existing code):
Widget build(BuildContext context) {
return specialByteScreen.match<Widget>(
unknown: () => SizedBox.shrink(),
winner: (model) => WinnerScreen(model),
battleStart: (model) => BattleStartScreen(model),
);
// ✅ Compiler enforces all parameters present
}
Serialization Migration
Before (generated serializer):
// In .g.dart file
class _$ContentBitUnionSerializer
implements StructuredSerializer<ContentBitUnion> {
@override
Iterable<Object?> serialize(Serializers serializers, ContentBitUnion object) {
// ... 50 lines of switch logic
}
@override
ContentBitUnion deserialize(
Serializers serializers,
Iterable<Object?> serialized,
) {
// ... 80 lines of deserialization logic
}
}
After (hand-written serializer):
class ContentBitUnionSerializer
implements StructuredSerializer<ContentBitUnion> {
@override
Iterable<Type> get types => const [ContentBitUnion];
@override
String get wireName => 'ContentBitUnion';
@override
Iterable<Object?> serialize(Serializers serializers, ContentBitUnion object) {
final typeName = object.unionTypeName;
final model = object.innerModel;
if (model == null) return [typeName];
return [
typeName,
serializers.serialize(model, specifiedType: FullType(model.runtimeType)),
];
}
@override
ContentBitUnion deserialize(
Serializers serializers,
Iterable<Object?> serialized,
) {
final iterator = serialized.iterator;
iterator.moveNext();
final typeName = iterator.current as String;
if (typeName == 'unknown') {
return ContentBitUnion.unknown();
}
iterator.moveNext();
final modelData = iterator.current;
return switch (typeName) {
'select_text' => ContentBitUnion.selectText(
serializers.deserialize(modelData,
specifiedType: FullType(SelectQuestionModel)) as SelectQuestionModel,
),
'bubble_pop' => ContentBitUnion.bubblePop(
serializers.deserialize(modelData,
specifiedType: FullType(BubblePopQuestionModel)) as BubblePopQuestionModel,
),
// ... 39 more cases
_ => ContentBitUnion.unknown(),
};
}
}
Result: Explicit, debuggable, no code generation needed.
ASCII Diagram: After State
┌──────────────────────────────────────────────────────────────┐
│ sealed class ContentBitUnion │
│ (Native Dart 3) │
│ │
│ factory ContentBitUnion.selectText(model) = ...Unknown; │
│ factory ContentBitUnion.bubblePop(model) = ...BubblePop; │
│ ... 39 more factory constructors │
│ │
│ T match<T>(...) => switch (this) { ... }; │
└────────────────────────┬─────────────────────────────────────┘
│
│ No code generation ✓
│
┌────────────────────────▼─────────────────────────────────────┐
│ Individual Case Classes (hand-written) │
│ │
│ class ContentBitUnionSelectText extends ContentBitUnion { │
│ final SelectQuestionModel model; │
│ String get unionTypeName => 'select_text'; │
│ Object? get innerModel => model; │
│ } │
│ │
│ class ContentBitUnionBubblePop extends ContentBitUnion { │
│ final BubblePopQuestionModel model; │
│ String get unionTypeName => 'bubble_pop'; │
│ Object? get innerModel => model; │
│ } │
│ │
│ ... 39 more case classes (~2,587 lines total) │
└──────────────────────────────────────────────────────────────┘
Build Pipeline:
┌─────────┐ ┌──────────────┐
│ Write │ ─▶ │ Compile │
│ Dart 3 │ │ (Native) │
│ sealed │ │ │
│ class │ │ No deps │
└─────────┘ └──────────────┘
▲
│ Language-guaranteed semantics ✓
Implementation Details
Migration Script
Created scripts/convert_built_union.py to automate conversion:
def convert_union_to_sealed_class(union_file_path):
"""Convert @BuiltUnion class to Dart 3 sealed class."""
union_info = parse_union_file(union_file_path)
sealed_class = generate_sealed_class(
name=union_info.name,
variants=union_info.variants,
)
case_classes = [
generate_case_class(variant)
for variant in union_info.variants
]
match_method = generate_match_method(union_info.variants)
serializer = generate_serializer(union_info)
write_migrated_file(sealed_class + case_classes + match_method + serializer)
delete_generated_file(union_file_path.replace('.dart', '.g.dart'))
Usage:
python scripts/convert_built_union.py \
lib/src/core/common/unions/content_bit_union.dart
Union Types Migrated
| Union | Variants | Before (generated) | After (hand-written) | Change | |-------|----------|-------------------|----------------------|--------| | ContentBitUnion | 41 | 1,261 lines | 610 lines | -51% | | SectionUnion | 56 | 913 lines | 841 lines | -8% | | SubSectionUnion | 19 | 412 lines | 292 lines | -29% | | ContentBitsUnion | 8 | 247 lines | 170 lines | -31% | | OnBoardingScreenUnion | 10 | 298 lines | 190 lines | -36% | | FloatingActionButtonUnion | 2 | 156 lines | 87 lines | -44% | | SpecialByteScreenUnion | 3 | 189 lines | 82 lines | -57% | | ContentBitsResultUnion | 7 | 210 lines | 153 lines | -27% | | Total | 146 | 3,686 lines | 2,425 lines | -34% |
Note: Hand-written code is more verbose per variant but eliminates generated file overhead.
Exhaustive Checking Benefits
Before (runtime checking):
Widget renderSection(SectionUnion section) {
if (section.isProgressHeader) {
return ProgressHeaderSection(section.asProgressHeader);
} else if (section.isSubjectsList) {
return SubjectsSection(section.asSubjectsList);
}
// ... 54 more if-else branches
// ❌ If you forget a case, no compiler error
// Bug discovered at runtime when that section type appears
}
After (compile-time checking):
Widget renderSection(SectionUnion section) {
return switch (section) {
SectionUnionProgressHeader(:final model) => ProgressHeaderSection(model),
SectionUnionSubjectsList(:final model) => SubjectsSection(model),
// ... 54 more cases
// ✅ Compiler error if case missing:
// "The type 'SectionUnion' is not exhaustively matched."
};
}
Impact and Results
Dependency Elimination
| Removed | Type | Risk Eliminated |
|---------|------|-----------------|
| built_union | Git fork dependency | Maintenance burden, security risk |
| built_union_generator | Git fork dependency | Build pipeline dependency on unmaintained fork |
| 2 git forks | Total | No more external repository dependencies |
Code Metrics
| Metric | Before | After | Change |
|--------|--------|-------|--------|
| .g.dart files | 8 files | 0 files | -100% |
| Generated code lines | 3,686 lines | 0 lines | -100% |
| Hand-written code | ~1,200 lines | ~2,587 lines | +1,387 lines |
| Net code change | 4,886 lines | 2,587 lines | -2,299 lines (-47%) |
| Type safety | Runtime | Compile-time | Improved |
| Build dependencies | 5 generators | 3 generators | -40% |
Build Pipeline Improvement
Before:
$ fvm flutter packages pub run build_runner build
[INFO] Generating build script...
[INFO] Generating build script completed, took 3.2s
[INFO] Running build...
[INFO] Running build completed, took 47.8s
[INFO] Caching finalized dependency graph completed, took 0.5s
After:
$ fvm flutter packages pub run build_runner build
[INFO] Generating build script...
[INFO] Generating build script completed, took 2.8s
[INFO] Running build...
[INFO] Running build completed, took 41.2s # 14% faster
[INFO] Caching finalized dependency graph completed, took 0.4s
Result: ~15% faster build due to fewer generators.
Lessons Learned
What Worked Well
- Migration script: Automated 80% of conversion, manual review for edge cases
- Backwards compatibility: Kept
.match()method, no breaking changes to call sites - Incremental migration: Converted 1 union at a time, tested between each
- Compile-time guarantees: Sealed classes catch missing cases immediately
What Could Be Improved
- Earlier adoption: Should have waited for Dart 3 before choosing built_union
- Documentation: Native sealed classes required more examples/comments than generated code
- Serialization testing: Should have added more round-trip tests before migration
Trade-offs Made
Code Verbosity:
- Before: 1 annotated class → 913 lines generated (opaque)
- After: 1 sealed class + 56 case classes → 841 lines hand-written (explicit)
- Decision: Prefer explicit hand-written code over magic code generation
API Compatibility:
- Kept
.match()method for backwards compatibility - Could have forced migration to native
switchexpressions - Decision: Gradual migration, both APIs coexist
Next Steps
Completed ✅
- All 9 union types migrated to sealed classes
- All 8
.g.dartfiles deleted - 2 git fork dependencies removed from pubspec.yaml
- Build pipeline simplified
- All tests passing (100% serialization compatibility)
Future Opportunities
- Migrate call sites to native switch: Replace
.match()calls withswitchexpressions - Pattern matching exploration: Leverage Dart 3 pattern matching features (destructuring, guards)
- Remove backwards-compatible match(): Once all call sites migrated, remove method
- Code generation elimination: Evaluate feasibility of removing remaining generators
Related Work
- EnumClass Migration: Replaced 70 EnumClass types with Dart 3 enhanced enums (see blog post #4)
- Serialization Modernization: Part of broader effort to reduce generated code from 191K to 170K lines (see blog post #12)
- MVC Refactoring: Union types used heavily in question/section dispatch (see blog posts #1, #2)
Date: February 2026
Commit: 36fa573
Files Changed: 22 files, +2,587 lines, -3,686 lines (generated)
Dependencies Removed: 2 (built_union, built_union_generator)
Test Coverage: 100% serialization round-trip tests passing