← Back

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 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 (_$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

| 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

  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