Ankit Ranjan
Back to Deep Dives

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.

May 24, 2026 7 topics 7 quiz questions
Share:
1

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
}
enum Status { loading, success, error }Status.loadingindex: 0Status.successindex: 1Status.errorindex: 2Status.values = [loading, success, error]list of all enum values in declaration orderEvery enum value has a .name (the string) and .index (its position)The .values static getter returns all values as a list

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?

2

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)
}
Enhanced enum: HttpStatus with code and message fieldsHttpStatus.okcode: 200message: 'OK'HttpStatus.notFoundcode: 404message: 'Not Found'HttpStatus.serverErrorcode: 500message: 'Internal...'final int code;final String message;const HttpStatus(this.code, this.message);Fields must be final. Constructors must be const.Each enum value calls the constructor with its specific data

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);
}

3

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.

4

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.

5

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.

6

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
}
Exhaustive switch — the compiler proves completenessreturn switch (direction) {Direction.north => '↑',Direction.south => '↓',Direction.east => '→',Direction.west => '←',};All 4 cases handledNo default neededCompiler verified// Missing Direction.west?Error: The type 'Direction' is not exhaustively matched.Add a new enum value? The compiler tells you everywhere to update.

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.

7

Enums vs sealed classes — when to use which

Both enums and sealed classes give us exhaustive switching. So when should we use which?

Enums vs Sealed ClassesFeatureenumsealed classValues are constAlwaysOptionalDifferent fields per variantNo (same for all)YesBuilt-in .index / .valuesYesNoCan extend a classNo (extends Enum)YesRuntime instance creationNo (fixed set)YesBest forFixed options, configState with varying dataUse enum when all values share the same shape. Use sealed when variants differ.

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.

Search

Loading search...