Enhanced Enums in Dart — Fields, Methods, and Type-Safe Switching
Enums are more than named constants. Dart 2.17 lets enums hold data, implement interfaces, and provide methods — bringing the power of classes to the simplicity of enumeration.
Basic enums — simple enumerated types
Sometimes a value can only be one of a small, fixed set of options. Days of the week. Directions on a compass. HTTP request methods. We could use strings or integers, but that leads to bugs.
// The fragile way — strings
String status = 'loading'; // typo: 'laoding' compiles fine
if (status == 'Loading') { // case mismatch — always false
// ...
}
Strings are flexible, but they're too flexible. The compiler can't catch our typos. Enter enums.
enum Status { loading, success, error }
void main() {
var current = Status.loading;
print(current); // Status.loading
print(current.name); // 'loading'
print(current.index); // 0
}
Every enum automatically has three things:
1. .index — the position in the declaration (0, 1, 2...)
2. .name — the string representation ('loading', 'success', 'error')
3. EnumType.values — a list of all values in declaration order
// Iterate all values
for (var s in Status.values) {
print('${s.name} is at index ${s.index}');
}
// loading is at index 0
// success is at index 1
// error is at index 2
// Parse from string (Dart 2.15+)
var parsed = Status.values.byName('success'); // Status.success
var maybe = Status.values.asNameMap()['error']; // Status.error
With enums, misspelling a value is a compile error. That alone makes them worth using. But basic enums have a limitation — they're just names. What if we want each value to carry data?
Enhanced enums — adding fields and constructors
Before Dart 2.17 (May 2022), enums were limited to bare values. If we wanted to attach data to enum values, we had to use a separate Map or a class hierarchy. Enhanced enums changed everything.
enum HttpStatus {
ok(200, 'OK'),
created(201, 'Created'),
badRequest(400, 'Bad Request'),
notFound(404, 'Not Found'),
serverError(500, 'Internal Server Error');
final int code;
final String message;
const HttpStatus(this.code, this.message);
}
Now each enum value carries its own data. We access it just like any other field.
void main() {
var status = HttpStatus.notFound;
print(status.code); // 404
print(status.message); // 'Not Found'
print(status.name); // 'notFound' (still works)
}
The rules for enhanced enums:
1. All fields must be final. Enum values are constants — they cannot change after creation.
2. Constructors must be const. Every enum value is a compile-time constant.
3. All values must be declared before any members. The values come first, then the semicolon, then fields/constructors/methods.
4. Values must call a constructor. Each value is an instance created by calling one of the enum's constructors.
// Named constructors work too
enum Priority {
low.withLevel(1),
medium.withLevel(5),
high.withLevel(10),
critical.withLevel(100);
final int level;
const Priority.withLevel(this.level);
}
Enum methods and getters
Enhanced enums aren't just data holders — they can have methods and computed properties. This lets us encapsulate behaviour right where the data lives.
enum Planet {
mercury(3.7),
venus(8.87),
earth(9.81),
mars(3.72),
jupiter(24.79),
saturn(10.44),
uranus(8.87),
neptune(11.15);
final double gravity; // m/s²
const Planet(this.gravity);
// Computed property
double get weightFactor => gravity / earth.gravity;
// Method
double calculateWeight(double earthWeight) {
return earthWeight * weightFactor;
}
// Static method for lookup
static Planet? fromName(String name) {
return values.cast<Planet?>().firstWhere(
(p) => p?.name.toLowerCase() == name.toLowerCase(),
orElse: () => null,
);
}
}
void main() {
var mars = Planet.mars;
print(mars.gravity); // 3.72
print(mars.weightFactor); // ~0.38
print(mars.calculateWeight(70)); // ~26.55 kg
var found = Planet.fromName('JUPITER');
print(found?.gravity); // 24.79
}
The pattern is powerful. Instead of scattering planet-related calculations across utility functions, we put them on the enum itself. The data and its behaviour live together.
// Another example: HTTP status categories
enum HttpStatus {
ok(200, 'OK'),
created(201, 'Created'),
badRequest(400, 'Bad Request'),
unauthorized(401, 'Unauthorized'),
notFound(404, 'Not Found'),
serverError(500, 'Internal Server Error');
final int code;
final String message;
const HttpStatus(this.code, this.message);
bool get isSuccess => code >= 200 && code < 300;
bool get isClientError => code >= 400 && code < 500;
bool get isServerError => code >= 500;
String toHeader() => '$code $message';
}
void main() {
var status = HttpStatus.notFound;
print(status.isClientError); // true
print(status.toHeader()); // '404 Not Found'
}
The methods make the enum self-documenting. We don't need to remember which ranges mean what — the enum tells us.
Enums implementing interfaces
Enums can implement interfaces. This is how we integrate them into larger type hierarchies. The enum values must satisfy the interface contract.
abstract class Describable {
String describe();
}
enum Colour implements Describable {
red(0xFF0000),
green(0x00FF00),
blue(0x0000FF),
white(0xFFFFFF),
black(0x000000);
final int hex;
const Colour(this.hex);
@override
String describe() => '$name (#${hex.toRadixString(16).padLeft(6, '0')})';
String get hexString => '#${hex.toRadixString(16).padLeft(6, '0').toUpperCase()}';
}
void printDescription(Describable item) {
print(item.describe());
}
void main() {
printDescription(Colour.red); // 'red (#ff0000)'
printDescription(Colour.blue); // 'blue (#0000ff)'
}
The implements keyword works the same as with classes. The enum must provide implementations for all abstract members.
Important limitation: Enums cannot extend other classes. The enum type already extends
Enum, and Dart doesn't allow multiple inheritance. But they can implement any number of interfaces.
// Multiple interfaces work fine
abstract class Serialisable {
Map<String, dynamic> toJson();
}
abstract class Comparable<T> {
int compareTo(T other);
}
enum Priority implements Serialisable, Comparable<Priority> {
low(1),
medium(5),
high(10);
final int level;
const Priority(this.level);
@override
Map<String, dynamic> toJson() => {'name': name, 'level': level};
@override
int compareTo(Priority other) => level.compareTo(other.level);
}
void main() {
var sorted = [Priority.high, Priority.low, Priority.medium]..sort();
print(sorted.map((p) => p.name)); // (low, medium, high)
}
By implementing Comparable, our enum values can be sorted. By implementing Serialisable, they can be converted to JSON. The enum participates in the type system like any other class.
Enums with generics
Enhanced enums can be generic. This is useful when each enum value handles a specific type of data.
enum JsonType<T> {
string<String>(),
number<num>(),
boolean<bool>(),
array<List>(),
object<Map>(),
nil<Null>();
Type get dartType => T;
bool matches(dynamic value) {
if (this == nil) return value == null;
return value is T;
}
}
void main() {
print(JsonType.string.dartType); // String
print(JsonType.string.matches('hello')); // true
print(JsonType.string.matches(42)); // false
print(JsonType.nil.matches(null)); // true
}
The generic parameter T is specified for each value. This creates a type-safe association between the enum value and a Dart type.
// A more practical example: form field types
enum FieldType<T> {
text<String>(''),
number<int>(0),
decimal<double>(0.0),
toggle<bool>(false),
date<DateTime?>(null);
final T defaultValue;
const FieldType(this.defaultValue);
bool isValid(dynamic value) => value is T;
}
class FormField<T> {
final String label;
final FieldType<T> type;
T value;
FormField(this.label, this.type) : value = type.defaultValue;
}
void main() {
var nameField = FormField('Name', FieldType.text);
nameField.value = 'Alice'; // type-safe: must be String
var ageField = FormField('Age', FieldType.number);
ageField.value = 25; // type-safe: must be int
}
Generic enums shine when building type-safe APIs. The enum value carries type information that flows through the rest of the code.
Exhaustive switching with enums
Enums have always enabled exhaustive switching in Dart. The compiler knows every possible value, so it can verify that a switch covers all cases.
enum Direction { north, south, east, west }
String getArrow(Direction d) {
return switch (d) {
Direction.north => '↑',
Direction.south => '↓',
Direction.east => '→',
Direction.west => '←',
};
// No default needed — compiler knows this is complete
}
The real power shows when we modify the enum. Add a new value, and the compiler flags every incomplete switch.
// Later, we add a new direction
enum Direction { north, south, east, west, up, down }
// Now getArrow() fails to compile:
// Error: The type 'Direction' is not exhaustively matched
// by the switch cases since it doesn't match 'Direction.up'
// or 'Direction.down'.
Compare this to a default case:
// With a default, new values are silently ignored
String getArrow(Direction d) {
return switch (d) {
Direction.north => '↑',
Direction.south => '↓',
Direction.east => '→',
Direction.west => '←',
_ => '?', // catches up, down — no compiler warning
};
}
The default case hides the problem. We won't know we forgot to handle up and down until runtime. Avoid default cases with enums unless you genuinely want a fallback.
Pattern matching with enhanced enums:
enum HttpStatus {
ok(200), notFound(404), serverError(500);
final int code;
const HttpStatus(this.code);
}
String categorise(HttpStatus status) {
return switch (status) {
HttpStatus(:var code) when code < 400 => 'Success',
HttpStatus(:var code) when code < 500 => 'Client Error',
HttpStatus() => 'Server Error',
};
}
The :var code syntax destructures the enum's field. The when guard adds conditions. This is Dart 3 pattern matching at work.
Enums vs sealed classes — when to use which
Both enums and sealed classes give us exhaustive switching. So when should we use which?
Use an enum when:
1. All values have the same fields. Every enum value has the same structure — just different data.
// Good enum: all values have code + message
enum HttpStatus {
ok(200, 'OK'),
notFound(404, 'Not Found');
final int code;
final String message;
const HttpStatus(this.code, this.message);
}
2. The set is truly fixed. Days of the week. Cardinal directions. HTTP methods. These won't change.
3. You need .index or .values. Enums provide these for free. Useful for serialisation, iteration, dropdowns.
Use a sealed class when:
1. Variants have different fields. Success has data; Error has a message and stack trace; Loading has nothing.
// Sealed is better: variants have different shapes
sealed class ApiResult {}
final class Loading extends ApiResult {}
final class Success extends ApiResult {
final Data data;
Success(this.data);
}
final class Error extends ApiResult {
final String message;
final StackTrace? trace;
Error(this.message, [this.trace]);
}
2. You need runtime instance creation. Sealed classes can be instantiated at runtime with different data. Enum values are fixed at compile time.
3. You need inheritance. Sealed subtypes can extend other classes; enums cannot.
The heuristic: If every variant looks the same (same fields, just different values), use an enum. If variants have different shapes, use a sealed class.
Test your understanding
7 questions
Seven questions covering basic enums, enhanced enums, methods, interfaces, generics, and exhaustive switching.