DEV Community

Cover image for Dart Enhanced Enums Are Secretly Factories: Unlocking Constructor Tearoffs

Dart Enhanced Enums Are Secretly Factories: Unlocking Constructor Tearoffs

This is Part 2 of the Dart and Flutter series—practical guides, architectural deep dives, and hard-earned engineering lessons from the field. Each article is completely standalone.


How many times have you written (or reviewed) a piece of code that looks like this?

NotificationWidget buildNotification(NotificationType type, NotificationData data) {
  switch (type) {
    case NotificationType.email:
      return EmailNotificationWidget(data);
    case NotificationType.sms:
      return SmsNotificationWidget(data);
    case NotificationType.push:
      return PushNotificationWidget(data);
  }
}
Enter fullscreen mode Exit fullscreen mode

Or worse, a dedicated NotificationWidgetFactory class containing a 40-line switch statement or a mutable Map<NotificationType, Function> registry.

It feels routine. It’s what we were taught in classic OOP textbooks. But it introduces subtle friction:

  • The enum NotificationType knows nothing about the widgets it represents.
  • The factory switch ladder must be updated every time a new case is added.
  • The creation logic is split across multiple files and layers.

What if your enum wasn't just a list of identifiers, but was itself the polymorphic factory?

By marrying two features of modern Dart—Enhanced Enums and Constructor Tearoffs—you can delete the switch ladders and turn your enum values into self-instantiating factories in under 15 lines of code.


The Two Ingredients: A Brief History

To understand how clean this pattern is, we have to appreciate two language features that quietly revolutionized Dart over the last couple of years:

1. Constructor Tearoffs (Dart 2.15+)

Before Dart 2.15, if you wanted to pass a constructor as a first-class function, you had to wrap it in an awkward lambda:

// The old, clunky way:
final builders = [(data) => EmailNotification(data)];
Enter fullscreen mode Exit fullscreen mode

Dart 2.15 introduced Constructor Tearoffs. Constructors became first-class closures. You can reference default constructors using .new, or named constructors directly by name:

// The modern Dart way:
final builders = [EmailNotification.new];
final parsers = [User.fromJson];
Enter fullscreen mode Exit fullscreen mode

2. Enhanced Enums (Dart 2.17+)

Before Dart 2.17, Dart enums were glorified integers. They had an index and a name, and virtually nothing else.

With Enhanced Enums, enums gained full class powers:

  • They can declare final fields.
  • They can have const constructors.
  • They can implement interfaces and mixins.
  • They can define methods, getters, and operator overloads.

When you put constructor tearoffs inside enhanced enums, something magical happens.


The Fusion: Enums as Self-Instantiating Factories

Let’s model a common domain scenario: a document rendering engine. We have different document types that share a common interface:

abstract class Document {
  String get title;
  void render();
}

class PdfDocument implements Document {
  @override
  final String title;
  PdfDocument(this.title);

  @override
  void render() => print('Rendering PDF: $title');
}

class MarkdownDocument implements Document {
  @override
  final String title;
  MarkdownDocument(this.title);

  @override
  void render() => print('Rendering Markdown: $title');
}

class HtmlDocument implements Document {
  @override
  final String title;
  HtmlDocument(this.title);

  @override
  void render() => print('Rendering HTML: $title');
}
Enter fullscreen mode Exit fullscreen mode

Now, instead of writing an external DocumentFactory or a switch statement, we declare an Enhanced Enum where each enum member holds a tearoff reference to its class constructor:

enum DocumentType {
  pdf(PdfDocument.new),
  markdown(MarkdownDocument.new),
  html(HtmlDocument.new);

  // A field holding a function that creates a Document given a String title
  final Document Function(String title) create;

  const DocumentType(this.create);
}
Enter fullscreen mode Exit fullscreen mode

Look closely at DocumentType:

  1. Document Function(String title) create: A strongly typed function signature stored as a final field.
  2. pdf(PdfDocument.new): We pass the constructor tearoff directly to the enum value.
  3. const DocumentType(this.create): The constructor is const, so the entire enum remains compile-time constant!

How You Use It

Instantiating a polymorphic object is now as simple as calling the field on the enum instance:

void main() {
  const selectedType = DocumentType.markdown;

  // Polymorphic instantiation with ZERO switch statements:
  final doc = selectedType.create('Architecture_Notes.md');

  doc.render(); // Output: Rendering Markdown: Architecture_Notes.md
}
Enter fullscreen mode Exit fullscreen mode

No switch statement. No map lookups. No reflection. If you add a new enum value (e.g. epub), the compiler forces you to supply a matching constructor tearoff right there. You cannot accidentally forget to handle it.


Real-World Example: Polymorphic API Payload Parsers

This pattern shines when deserializing polymorphic JSON payloads (like webhooks, analytics events, or push notifications).

Imagine an incoming stream of server events:

{
  "type": "login",
  "payload": {"userId": "usr_42", "timestamp": 1711000000}
}
Enter fullscreen mode Exit fullscreen mode

We have distinct payload models:

abstract class EventPayload {}

class LoginPayload implements EventPayload {
  final String userId;
  LoginPayload.fromJson(Map<String, dynamic> json) : userId = json['userId'] as String;
}

class PurchasePayload implements EventPayload {
  final double amount;
  PurchasePayload.fromJson(Map<String, dynamic> json) : amount = (json['amount'] as num).toDouble();
}
Enter fullscreen mode Exit fullscreen mode

Instead of a bulky JSON parsing switch, our enum maps incoming strings directly to the named constructor tearoff (.fromJson):

enum EventType {
  login(LoginPayload.fromJson),
  purchase(PurchasePayload.fromJson);

  final EventPayload Function(Map<String, dynamic>) fromJson;
  const EventType(this.fromJson);

  static EventType? fromString(String name) =>
      EventType.values.where((e) => e.name == name).firstOrNull;
}
Enter fullscreen mode Exit fullscreen mode

Now, your dispatcher parses any incoming event in two clean lines:

EventPayload parseEvent(String typeName, Map<String, dynamic> rawPayload) {
  final eventType = EventType.fromString(typeName) ?? 
      (throw UnsupportedError('Unknown event: $typeName'));

  return eventType.fromJson(rawPayload);
}
Enter fullscreen mode Exit fullscreen mode

Real-World Example: Flutter Widget Builders

In Flutter applications, you frequently have a selection control (tabs, filters, or segmented buttons) that drives which widget to render:

enum DashboardView {
  analytics(AnalyticsView.new),
  activity(ActivityView.new),
  settings(SettingsView.new);

  final Widget Function({Key? key}) builder;
  const DashboardView(this.builder);
}
Enter fullscreen mode Exit fullscreen mode

In your widget tree:

class DashboardScreen extends StatelessWidget {
  final DashboardView currentView;
  const DashboardScreen({super.key, required this.currentView});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: currentView.builder(),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

When you add a 4th tab tomorrow, you don't hunt through widget tree switch statements—you simply declare it on the enum.


A Senior Perspective: When NOT to Use This Pattern

Every pattern has architectural boundaries. While enum constructor tearoffs are powerful, here are two caveats to keep in mind:

1. Beware of Layer Inversion (Separation of Concerns)

If your enum lives in your pure Dart Domain Layer (core business logic), do not attach Flutter widget constructor tearoffs to it. Doing so couples your domain models to package:flutter.

  • Good: An enum in the presentation layer mapping UI modes to widget constructors.
  • Good: An enum in the data layer mapping API event types to DTO constructors.
  • Bad: A domain entity enum importing flutter/material.dart.

2. When to Prefer Dart 3 Sealed Classes & Pattern Matching

Dart 3 introduced sealed class hierarchies and exhaustive switch expressions:

// Alternative: Dart 3 pattern matching
Widget buildView(DashboardView view) => switch (view) {
  DashboardView.analytics => const AnalyticsView(),
  DashboardView.activity => const ActivityView(),
  DashboardView.settings => const SettingsView(),
};
Enter fullscreen mode Exit fullscreen mode

Which should you choose?

  • Use Enum Constructor Tearoffs when the creation parameters are identical, the association between the enum and the class is 1:1, and you want self-contained encapsulation with zero boilerplate.
  • Use Sealed Classes & Pattern Matching when each subclass takes radically different parameters, when cases have unique construction logic, or when you want to avoid coupling the enum to the concrete implementations.

Conclusion

Enhanced Enums and Constructor Tearoffs are two of modern Dart's finest language ergonomics. When combined, they eliminate entire classes of boilerplate:

  1. Self-documenting: The enum member explicitly declares the constructor that builds it.
  2. Compile-time safe: Missing a constructor is impossible; the compiler will not let you compile an enum member without satisfying the signature.
  3. Zero switch statements: Replaces sprawling factory classes with a clean, single-line invocation.

Next time you catch yourself writing a 30-line switch statement just to instantiate a class from an enum value, pause. Let the enum do the work.


What's your take?

Have you started using constructor tearoffs in your enums, or do you prefer Dart 3 switch expressions? Let me know in the comments below!


Randal L. Schwartz is a Google Developer Expert (GDE) for Dart & Flutter and veteran software architect.

Top comments (2)

Collapse
 
kyisaiah47 profile image
Isaiah Kim

I'd want to see the boundary when enum members need different constructor inputs, such as a parsed JSON map for one type and a validated domain object for another.

Collapse
 
randalschwartz profile image
Randal L. Schwartz Google Developer Experts

That hits the exact architectural boundary!

The core requirement for the Enum + Constructor Tearoff pattern is signature symmetry. Because an enhanced enum is a single class with a fixed set of fields, the stored tearoff signature must be identical across every enum member:

enum DocumentType {
  pdf(PdfDocument.new),
  html(HtmlDocument.new);

  // Every member MUST satisfy this exact function signature:
  final Document Function(String title) create;
  const DocumentType(this.create);
}
Enter fullscreen mode Exit fullscreen mode

The moment your members require heterogeneous (asymmetric) inputs—for example, one requiring a raw Map<String, dynamic> and another requiring an already-validated domain object—you've crossed the boundary where enums stop being the right tool.

Trying to force an enum across that boundary usually produces painful code smells:

  1. Untyped arguments: final Object Function(dynamic) create; (destroys compile-time type safety).
  2. Kitchen-sink parameter bags: final Function({Map<String, dynamic>? json, ValidatedDomainObject? domain}) create; (forces members to ignore parameters they do not need, inviting runtime errors).

When you cross that boundary: Dart 3 Sealed Classes

Once construction inputs diverge, the idiomatic modern Dart solution is to switch to sealed class hierarchies and exhaustive pattern matching. Instead of an enum dispatching identical arguments, model the divergent inputs or payloads as a sealed hierarchy:

sealed class EventSource {}

class RawJsonSource extends EventSource {
  final Map<String, dynamic> json;
  RawJsonSource(this.json);
}

class ValidatedSource extends EventSource {
  final ValidatedDomainObject domain;
  ValidatedSource(this.domain);
}

// Exhaustive, type-safe dispatch across heterogeneous inputs:
EventPayload createPayload(EventSource source) => switch (source) {
  RawJsonSource(:final json) => RawEventPayload.fromJson(json),
  ValidatedSource(:final domain) => DomainEventPayload.fromDomain(domain),
};
Enter fullscreen mode Exit fullscreen mode

Rule of thumb:

  • Use Enum Constructor Tearoffs when construction is symmetric across all variants (for example, every variant parses from a JSON map, or every variant builds from BuildContext).
  • Use Sealed Classes + Pattern Matching when construction is asymmetric and variants require fundamentally different input dependencies.