← Back to blog
Object-Oriented Dart · Part 8 of 11
July 1, 202611 min read

Enhanced Enums in Dart: Enums That Behave Like Classes

DartOOPFlutter

Enhanced Enums in Dart

This is Part 8 of the Object-Oriented Dart series. So far we've built classes from every angle. Now we meet a special kind of class you've probably used without thinking of it as OOP: the enum.

An enum (enumeration) represents a fixed, known set of constant values — the days of the week, the suits in a deck, the states of a network request. In older languages enums are just named integers. Dart's are real objects, and since Dart 2.17 they can carry fields, methods, and constructors — "enhanced enums." That makes them one of the most underused-yet-powerful tools in the language.


Why enums beat loose constants

Before enums, people modelled fixed sets with strings or ints:

String status = 'pending'; // or 'active', or 'cancelled'... or a typo

This is fragile. Nothing stops status = 'pendng' or status = 'banana'. The compiler can't help you, and you can't get an exhaustive list of valid values. Enums fix all three problems: a closed set, compile-time checking, and self-documenting names.

enum Status { pending, active, cancelled }

Status status = Status.active; // only these three values are possible
// Status status = Status.banana; // ❌ compile error

Simple enums

The basic form lists the values inside enum:

enum Direction { north, east, south, west }

void main() {
  Direction heading = Direction.north;
  print(heading); // Direction.north
}

Every enum gives you three things for free:

print(Direction.north.index);   // 0  — zero-based position
print(Direction.west.index);    // 3
print(Direction.values);        // [Direction.north, ..., Direction.west]
print(Direction.east.name);     // 'east'  — the value's name as a String
  • .index — the value's zero-based position in the declaration.
  • .values — a List of all values, in order. Great for iterating or building dropdowns.
  • .name — the value's name as a string (cleaner than parsing toString()).

And you can parse back from a name:

final d = Direction.values.byName('south'); // Direction.south

Enums in switch (and why they shine there)

Enums and switch are made for each other. Because an enum has a known, finite set of values, Dart can check that your switch handles all of them — this is exhaustiveness checking:

enum TrafficLight { red, yellow, green }

String action(TrafficLight light) {
  return switch (light) {
    TrafficLight.red => 'Stop',
    TrafficLight.yellow => 'Slow down',
    TrafficLight.green => 'Go',
  };
}

Notice there's no default case — and that's the point. If you later add TrafficLight.flashing, the compiler immediately flags this switch as non-exhaustive: "you forgot to handle flashing." It turns "I added a case and forgot to update the logic somewhere" from a runtime surprise into a compile error. This safety is one of the best reasons to prefer enums over strings.

Tip: resist adding a default to an exhaustive enum switch just in case. The default silences the exhaustiveness check — the very thing that protects you. Handle each value explicitly and let the compiler watch your back.


Enhanced enums: adding fields and methods

Here's where Dart goes beyond most languages. An enum value can carry data and behavior, because under the hood each value is an instance of the enum's class.

Suppose each planet should know its mass and radius and be able to compute surface gravity:

enum Planet {
  // Each value calls the constructor with its own data.
  mercury(mass: 3.30e23, radius: 2.44e6),
  earth(mass: 5.97e24, radius: 6.37e6),
  jupiter(mass: 1.90e27, radius: 7.15e7);

  // A const constructor — REQUIRED for enhanced enums.
  const Planet({required this.mass, required this.radius});

  // Fields must be `final`.
  final double mass;
  final double radius;

  // Regular methods and getters work just like a normal class.
  double get surfaceGravity => 6.67e-11 * mass / (radius * radius);

  double weightOf(double earthWeight) {
    final earthGravity = Planet.earth.surfaceGravity;
    return earthWeight / earthGravity * surfaceGravity;
  }
}

void main() {
  print(Planet.earth.surfaceGravity.toStringAsFixed(2));   // 9.82
  print(Planet.jupiter.weightOf(70).toStringAsFixed(1));   // 165.6
}

Read that top-to-bottom:

  1. Each value — mercury, earth, jupiter — is declared with constructor arguments (its own mass and radius).
  2. A const constructor wires those arguments into fields. (Enhanced enums require the constructor to be const, and all fields to be final — every enum value is a compile-time constant.)
  3. Then it's just a class: getters (surfaceGravity), methods (weightOf), the lot.

This is a quantum leap over integer enums. Each value is a fully-formed, immutable object that knows things and does things — yet you still get the closed set, .values, and exhaustive switches.

The required structure

Enhanced enums have a strict layout, and the analyzer enforces it:

  1. The value declarations come first, separated by commas, ending with a semicolon (;) before the rest of the body.
  2. Any generative constructor must be const.
  3. Instance fields must be final.

That semicolon after the last value is easy to forget — if your enhanced enum won't compile, check for it first.


Enums can implement interfaces and use mixins

Because enum values are objects, an enum can implements an interface or mix in behavior with with — though it can't extends a class (it implicitly extends Enum). This lets enums slot neatly into polymorphic code:

enum Vehicle implements Comparable<Vehicle> {
  bicycle(tires: 2, passengers: 1),
  car(tires: 4, passengers: 5),
  bus(tires: 6, passengers: 50);

  const Vehicle({required this.tires, required this.passengers});

  final int tires;
  final int passengers;

  @override
  int compareTo(Vehicle other) => passengers - other.passengers;
}

void main() {
  final fleet = Vehicle.values.toList()..sort(); // uses compareTo
  print(fleet); // [Vehicle.bicycle, Vehicle.car, Vehicle.bus]
}

Vehicle implements Comparable, so a list of its values sorts by passenger count. An enum behaving like any other value type — that's the OOP payoff.


A practical pattern: behavior per value

A lovely use of enhanced enums is attaching different behavior to each value without a sprawling switch. Consider operations in a calculator:

enum Operation {
  add('+'),
  subtract('-'),
  multiply('×'),
  divide('÷');

  const Operation(this.symbol);
  final String symbol;

  double apply(double a, double b) => switch (this) {
        Operation.add => a + b,
        Operation.subtract => a - b,
        Operation.multiply => a * b,
        Operation.divide => a / b,
      };
}

void main() {
  for (final op in Operation.values) {
    print('6 ${op.symbol} 3 = ${op.apply(6, 3)}');
  }
  // 6 + 3 = 9.0
  // 6 - 3 = 3.0
  // 6 × 3 = 18.0
  // 6 ÷ 3 = 2.0
}

Each value carries its display symbol, and apply uses an exhaustive switch (this) — add a new operation and the compiler reminds you to handle it. Clean, closed, and self-contained.


Practice Challenges

Challenge 1 — Basic enum + switch. Define a Weekday enum and a function isWeekend(Weekday) using an exhaustive switch (no default).

Show solution
enum Weekday { mon, tue, wed, thu, fri, sat, sun }

bool isWeekend(Weekday day) => switch (day) {
      Weekday.sat || Weekday.sun => true,
      Weekday.mon ||
      Weekday.tue ||
      Weekday.wed ||
      Weekday.thu ||
      Weekday.fri =>
        false,
    };

void main() => print(isWeekend(Weekday.sun)); // true

The switch handles every value (the || patterns group several at once), so no default is needed — and adding an eighth day would trigger a compile error here.

Challenge 2 — values, name, index. Print all values of a Suit enum (hearts, diamonds, clubs, spades), each with its index and name.

Show solution
enum Suit { hearts, diamonds, clubs, spades }

void main() {
  for (final s in Suit.values) {
    print('${s.index}: ${s.name}');
  }
  // 0: hearts
  // 1: diamonds
  // 2: clubs
  // 3: spades
}

Suit.values iterates in declaration order; .index and .name give the position and string label for free.

Challenge 3 — Enhanced enum with a field. Make a Coin enum where each value carries its cents value (penny = 1, nickel = 5, dime = 10, quarter = 25). Print each coin's cents.

Show solution
enum Coin {
  penny(1),
  nickel(5),
  dime(10),
  quarter(25);

  const Coin(this.cents);
  final int cents;
}

void main() {
  for (final c in Coin.values) {
    print('${c.name}: ${c.cents}¢');
  }
}

Each value passes its cents to the const constructor, which stores it in the final field. Don't forget the semicolon after quarter(25).

Challenge 4 — Enum method. Add a double get dollars getter to Coin that returns its value in dollars.

Show solution
enum Coin {
  penny(1),
  nickel(5),
  dime(10),
  quarter(25);

  const Coin(this.cents);
  final int cents;

  double get dollars => cents / 100;
}

void main() {
  print(Coin.quarter.dollars); // 0.25
}

Once the value declarations and constructor are in place, an enhanced enum takes getters and methods exactly like a normal class.

Challenge 5 — Behavior per value. Create a LogLevel enum (debug, info, warning, error) with a prefix field and a shouldLog(LogLevel min) method that returns whether this level is at least as severe as min.

Show solution
enum LogLevel {
  debug('🐛'),
  info('ℹ️'),
  warning('⚠️'),
  error('🛑');

  const LogLevel(this.prefix);
  final String prefix;

  bool shouldLog(LogLevel min) => index >= min.index;
}

void main() {
  print(LogLevel.error.shouldLog(LogLevel.warning)); // true
  print(LogLevel.debug.shouldLog(LogLevel.warning)); // false
}

Because severity matches declaration order, comparing .index gives the "at least as severe" check for free — a neat use of the built-in ordering.

Challenge 6 — Enum implements an interface. Make a Priority enum (low, medium, high) implement Comparable<Priority> so a list of priorities sorts correctly.

Show solution
enum Priority implements Comparable<Priority> {
  low,
  medium,
  high;

  @override
  int compareTo(Priority other) => index.compareTo(other.index);
}

void main() {
  final tasks = [Priority.high, Priority.low, Priority.medium]..sort();
  print(tasks); // [Priority.low, Priority.medium, Priority.high]
}

This is a simple enum (no fields), but it can still implement Comparable and add the compareTo method, so ..sort() orders the list by priority. Enum values are real objects, so they fit polymorphic code like any other type.


Questions to test yourself

Q1 (basic). What problem do enums solve compared to using Strings or ints for a fixed set of options?

Show answer

They give you a closed, compile-time-checked set of valid values. You can't assign an invalid value (no typos like 'pendng'), you get a complete list via .values, and the names are self-documenting. The compiler enforces correctness that loose strings/ints cannot.

Q2 (basic). What do .index, .values, and .name give you?

Show answer

.index → the value's zero-based position in the declaration. .values → a List of all the enum's values in order. .name → the value's name as a String.

Q3 (intermediate). Why is a switch over an enum safer without a default clause?

Show answer

Without a default, Dart performs exhaustiveness checking and warns at compile time if any enum value isn't handled. If you later add a new value, every such switch becomes a compile error pointing you to the code that needs updating. Adding a default silences this check, so a forgotten case would slip through to runtime.

Q4 (intermediate). What three structural rules must an enhanced enum follow?

Show answer

(1) The value declarations come first and are terminated with a semicolon before the rest of the body. (2) Any generative constructor must be const. (3) Instance fields must be final. (These all follow from each enum value being a compile-time constant object.)

Q5 (advanced). How can each enum value carry different data, and what is each value actually, under the hood?

Show answer

Each value is declared with constructor arguments (e.g. earth(mass: 5.97e24, radius: 6.37e6)), which a const constructor stores into final fields. Under the hood, every enum value is a single, canonical, compile-time-constant instance of the enum's class — so the enum is really a class with a fixed, finite set of pre-built instances, each able to hold its own data and respond to methods.

Q6 (advanced). An enum can implements interfaces and use with mixins, but cannot extends a class. Why the asymmetry?

Show answer

Every enum implicitly extends the special Enum class, and Dart allows only single inheritance — so the extends slot is already taken, leaving no room for another superclass. implements (contracts) and with (mixins) don't conflict with that single-inheritance slot, so enums can satisfy interfaces and gain mixed-in behavior, letting them participate in polymorphic code just like ordinary classes.


Wrapping up

Enums are a small feature with outsized value once you treat them as the restricted classes they are:

  • A simple enum is a closed, type-safe set of named constants with free .index, .values, and .name.
  • Enums and exhaustive switch are a safety powerhouse — skip the default so the compiler enforces complete handling.
  • Enhanced enums carry final fields, const constructors, and methods/getters — each value is a real, immutable object with its own data and behavior.
  • Enums can implements interfaces and use with mixins (but not extends), so they fit polymorphic designs.

In Part 9 we look at how to add behavior to types you don't own — even String and int — with extension methods and extension types.