Dart 3 Sealed Classes: Replacing built_union Code Generation

·architecture

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 isEnumClass bug requiring patch-1 branch
  • 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 TypeVariantsGenerated Lines
ContentBitUnion411,261
SectionUnion56913
ContentBitsUnion8247
SubSectionUnion19412
OnBoardingScreenUnion10298
FloatingActionButtonUnion2156
SpecialByteScreenUnion3189
ContentBitsResultUnion7210

Generated code included:

  • Enum type definitions (_$ContentBitType with one value per variant)
  • .match<T>() method with 41-56 callback parameters
  • .isXxx getters for each variant (runtime type checking)
  • StructuredSerializer for 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

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 isEnumClass bug requiring patch-1 branch 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 TypeVariantsGenerated LinesContentBitUnion411,261SectionUnion56913ContentBitsUnion8247SubSectionUnion19412OnBoardingScreenUnion10298FloatingActionButtonUnion2156SpecialByteScreenUnion3189ContentBitsResultUnion7210 Generated code included: Enum type definitions (_$ContentBitType with one value per variant) .match<T>() method with 41-56 callback parameters .isXxx getters for each variant (runtime type checking) StructuredSerializer for 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
UnionVariantsBefore (generated)After (hand-written)Change
ContentBitUnion411,261 lines610 lines-51%
SectionUnion56913 lines841 lines-8%
SubSectionUnion19412 lines292 lines-29%
ContentBitsUnion8247 lines170 lines-31%
OnBoardingScreenUnion10298 lines190 lines-36%
FloatingActionButtonUnion2156 lines87 lines-44%
SpecialByteScreenUnion3189 lines82 lines-57%
ContentBitsResultUnion7210 lines153 lines-27%
Total1463,686 lines2,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

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
RemovedTypeRisk Eliminated
built_unionGit fork dependencyMaintenance burden, security risk
built_union_generatorGit fork dependencyBuild pipeline dependency on unmaintained fork
2 git forksTotalNo more external repository dependencies

Code Metrics

Code Metrics
MetricBeforeAfterChange
.g.dart files8 files0 files-100%
Generated code lines3,686 lines0 lines-100%
Hand-written code~1,200 lines~2,587 lines+1,387 lines
Net code change4,886 lines2,587 lines-2,299 lines (-47%)
Type safetyRuntimeCompile-timeImproved
Build dependencies5 generators3 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

  1. Migration script: Automated 80% of conversion, manual review for edge cases
  2. Backwards compatibility: Kept .match() method, no breaking changes to call sites
  3. Incremental migration: Converted 1 union at a time, tested between each
  4. Compile-time guarantees: Sealed classes catch missing cases immediately

What Could Be Improved

  1. Earlier adoption: Should have waited for Dart 3 before choosing built_union
  2. Documentation: Native sealed classes required more examples/comments than generated code
  3. 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 switch expressions
  • Decision: Gradual migration, both APIs coexist

Next Steps

Completed ✅

  • All 9 union types migrated to sealed classes
  • All 8 .g.dart files deleted
  • 2 git fork dependencies removed from pubspec.yaml
  • Build pipeline simplified
  • All tests passing (100% serialization compatibility)

Future Opportunities

  1. Migrate call sites to native switch: Replace .match() calls with switch expressions
  2. Pattern matching exploration: Leverage Dart 3 pattern matching features (destructuring, guards)
  3. Remove backwards-compatible match(): Once all call sites migrated, remove method
  4. 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