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:
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:
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:
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:
enum LoadingState {
idle,
loading,
success,
failure,
}
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.
Enhanced enums can own related behavior#
Dart's enhanced enums can define constant fields, constructors, methods, and getters. That is useful when every value has stable metadata.
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:
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:
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:
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.