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);
}
}
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
NotificationTypeknows 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)];
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];
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
finalfields. - They can have
constconstructors. - 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');
}
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);
}
Look closely at DocumentType:
-
Document Function(String title) create: A strongly typed function signature stored as afinalfield. -
pdf(PdfDocument.new): We pass the constructor tearoff directly to the enum value. -
const DocumentType(this.create): The constructor isconst, 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
}
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}
}
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();
}
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;
}
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);
}
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);
}
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(),
);
}
}
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(),
};
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:
- Self-documenting: The enum member explicitly declares the constructor that builds it.
- Compile-time safe: Missing a constructor is impossible; the compiler will not let you compile an enum member without satisfying the signature.
- 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.
- 📺 Watch deep dives and walkthroughs on YouTube: @RandalOnDartAndFlutter
- 💻 Connect on GitHub: @RandalSchwartz