Skip to content
AB

Better Dart Models with Enums: From Safer States to Rich Domain Types

Replace fragile strings with type-safe Dart enums, use exhaustive switches, and learn when enhanced enums make Flutter code clearer-and when they do not.

Ashish BhakhandPublished Updated

Strings are convenient until they start carrying meaning.

One screen checks for "admin", another receives "Admin", and an API returns "administrator". All three values compile. Only one may be correct. The mistake hides until that branch runs in production.

When a value must be one option from a fixed set, an enum gives the compiler enough information to help. Dart enums can also contain fields, constructors, methods, and implemented interfaces, making them useful domain types rather than dressed-up constants.

Replace conventions with a type#

Consider a role stored as a string:

Code
void openDashboard(String role) {
  if (role == 'admin') {
    // Show admin tools.
  }
}

openDashboard('admn'); // Compiles, but never matches.

The function accepts every possible string even though the domain accepts only three roles. Encode that constraint:

Code
enum UserRole {
  admin,
  member,
  guest,
}

void openDashboard(UserRole role) {
  if (role == UserRole.admin) {
    // Show admin tools.
  }
}

Now openDashboard('admn') is a type error. The API describes the values it accepts, autocomplete exposes the options, and renaming a value can be handled by tooling.

Let exhaustive switches guard changes#

Enums work especially well with Dart's switch expressions:

Code
String roleLabel(UserRole role) => switch (role) {
      UserRole.admin => 'Administrator',
      UserRole.member => 'Member',
      UserRole.guest => 'Guest',
    };

If you add UserRole.moderator, the analyzer reports that the switch no longer covers every case. That compiler error is useful: it reveals every decision that must account for the new role.

Avoid a catch-all branch when every enum value deserves explicit behavior. A wildcard can hide the exact change the exhaustive switch was meant to surface.

Model UI states that cannot contradict themselves#

An enum is a good fit for a mutually exclusive state:

Code
enum LoadingState {
  idle,
  loading,
  success,
  failure,
}
Code
Widget buildContent(LoadingState state) {
  return switch (state) {
    LoadingState.idle => const Text('Choose an item'),
    LoadingState.loading => const CircularProgressIndicator(),
    LoadingState.success => const Text('Loaded successfully'),
    LoadingState.failure => const Text('Something went wrong'),
  };
}

This is safer than independent flags such as isLoading, hasData, and hasError, which can produce contradictory combinations.

An enum alone is not enough when each state carries different data. If success needs a list and failure needs an error, use a sealed class hierarchy instead. The shape of the data should guide the type.

Dart's enhanced enums can define constant fields, constructors, methods, and getters. That is useful when every value has stable metadata.

Code
enum NoticeType {
  info(
    icon: Icons.info_outline,
    color: Colors.blue,
    semanticLabel: 'Information',
  ),
  warning(
    icon: Icons.warning_amber,
    color: Colors.orange,
    semanticLabel: 'Warning',
  ),
  error(
    icon: Icons.error_outline,
    color: Colors.red,
    semanticLabel: 'Error',
  ),
  success(
    icon: Icons.check_circle_outline,
    color: Colors.green,
    semanticLabel: 'Success',
  );

  const NoticeType({
    required this.icon,
    required this.color,
    required this.semanticLabel,
  });

  final IconData icon;
  final Color color;
  final String semanticLabel;
}

The presentation code becomes direct:

Code
void showNotice({
  required BuildContext context,
  required NoticeType type,
  required String message,
}) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      backgroundColor: type.color,
      content: Row(
        children: [
          Icon(type.icon, color: Colors.white),
          const SizedBox(width: 8),
          Expanded(child: Text(message)),
        ],
      ),
    ),
  );
}

Properties that always belong to a notice type live beside that type. Callers cannot accidentally pair an error icon with a success color.

Parse external values at the boundary#

APIs and storage systems usually deal in strings. Do not scatter conversions across the app. Parse once at the boundary:

Code
UserRole parseUserRole(String value) {
  return switch (value.toLowerCase()) {
    'admin' => UserRole.admin,
    'member' => UserRole.member,
    'guest' => UserRole.guest,
    _ => throw FormatException('Unknown user role: $value'),
  };
}

For a tolerant client, you might include an explicit unknown value instead of throwing. That choice should reflect the product: is an unknown role invalid data, or must the app continue safely when a newer server adds a role?

Do not persist enum.index. Reordering the declaration changes the numeric index and can corrupt stored meaning. Persist a deliberate stable value instead:

Code
enum UserRole {
  admin('admin'),
  member('member'),
  guest('guest');

  const UserRole(this.wireValue);

  final String wireValue;
}

Use enums when the set is truly closed#

Enums are ideal when:

  • the possible values are known at compile time;
  • values are mutually exclusive;
  • every value shares the same kind of metadata or behavior;
  • exhaustive handling is valuable.

Choose another model when values are downloaded from a server, created by users, configured remotely, or need different data shapes. A class, sealed hierarchy, or backend-driven configuration may describe those cases better.

Small type, large payoff#

Enums turn an informal naming convention into a contract. They remove typo-prone comparisons, improve autocomplete, make switches safer, and can keep stable value-specific behavior in one place.

The next time a parameter accepts “one of these strings,” pause. If that set is fixed and meaningful, an enum will usually make the code easier to change and harder to misuse.

← Back to all writing