İçeriğe geç / Skip to content / Zum Inhalt

Dart Enum and Composition: How to Organize Your Code

Ahmet Balaman

Last updated:

7 min read

FlutterDartEnumCompositionOOP
Dart Enum and Composition: How to Organize Your Code

Two habits show up again and again in the code of people new to Dart. The first is representing fixed options as text: a pizza size travels around as the strings "small", "medium" and "large". The second is building a relationship between two classes by copying fields: the car class gets the owner's name, age and ID typed in one by one. This post covers Dart's answer to both problems: enums and composition.

If you've read type conversions and static members, you have the groundwork. The examples are written for Dart 3; switch expressions and enums that carry fields, which you won't find in older tutorials, are front and center here.

Live Demo: Dart Enum and Composition

Try enum and composition usage interactively:

The Problem with Storing Options as Strings

First, let's look at the problem enums solve:

void placeOrder(String size) {
  if (size == 'medium') {
    print('Preparing a medium pizza');
  } else if (size == 'large') {
    print('Preparing a large pizza');
  } else {
    print('Preparing a small pizza');
  }
}

void main() {
  placeOrder('Medium'); // Capital letter: silently runs the "small" branch
}

The code compiles, runs and gives the wrong result. The compiler can't know the difference between 'Medium' and 'medium', because to it both are just strings. An enum turns the possible values into a type; you can't even write a value that isn't on the list.

Enum Basics

enum Color { red, blue, white }

void main() {
  final selected = Color.blue;

  print(selected.name);  // blue
  print(selected.index); // 1
  print(Color.values);   // [Color.red, Color.blue, Color.white]
}

Every enum comes with three things for free: name, which gives the value's name; index, which gives its position; and the values list containing all values. Your editor also suggests the options as soon as you type Color..

Switch and Exhaustiveness Checking

Dart 3 brought two important changes to switch. First, when a non-empty case finishes, execution leaves the switch automatically; the break lines you see in older examples are no longer needed. Second, a switch over an enum must be exhaustive: if you leave out a value, the code doesn't compile.

When you're producing a value, a switch expression is shorter:

enum Color { red, blue, white }

String colorLabel(Color color) => switch (color) {
      Color.red => 'Red',
      Color.blue => 'Blue',
      Color.white => 'White',
    };

void main() {
  print(colorLabel(Color.blue)); // Blue
}

Imagine you add green to the enum one day. The compiler flags every switch that doesn't handle the new value as an error; you don't have to go hunting for the places that need updating.

There's a mistake learners make often here: adding a default or _ at the end of the switch "just in case." That turns off exhaustiveness checking. The new green value silently falls into the default branch, and you only notice the bug at runtime. When you switch over an enum, listing every value explicitly lets the compiler stand guard for you.

Enums with Fields and Methods

Enums are more than a list of names. Since Dart 2.17 you can attach data to each value and add methods and getters to the enum:

import 'dart:math';

enum PizzaSize {
  small(diameter: 25, price: 180),
  medium(diameter: 30, price: 240),
  large(diameter: 35, price: 310);

  const PizzaSize({required this.diameter, required this.price});

  final int diameter; // centimeters
  final int price;

  double get area => pi * (diameter / 2) * (diameter / 2);

  double get pricePerSquareCm => price / area;

  bool isBiggerThan(PizzaSize other) => diameter > other.diameter;
}

void main() {
  const choice = PizzaSize.medium;
  print('${choice.name}: ${choice.diameter} cm, ${choice.price}');
  print(choice.pricePerSquareCm.toStringAsFixed(3));
  print(PizzaSize.large.isBiggerThan(choice)); // true
}

Now everything about a size lives in one place. No more scattered mappings like if (size == 'medium') price = 240; in some other file.

The documentation lists the rules for these enums clearly:

  • All values are declared at the very beginning of the enum, and there must be at least one.
  • Instance variables must be final, including those added by mixins.
  • Generative constructors must be const. A factory constructor can only return one of the existing values.
  • An enum can't extends another class, because it automatically extends Enum. It can use implements and with, though.
  • index, hashCode and == can't be overridden, and no member may be named values.

From Text to Enum: byName and asNameMap

The int.parse / int.tryParse pair from the previous post has an enum counterpart. To turn the text 'medium' coming from JSON or local storage such as shared_preferences into an enum value:

enum PizzaSize { small, medium, large }

void main() {
  final size = PizzaSize.values.byName('medium'); // PizzaSize.medium
  print(size);

  // PizzaSize.values.byName('huge'); // throws an ArgumentError

  final safe = PizzaSize.values.asNameMap()['huge'] ?? PizzaSize.medium;
  print(safe); // PizzaSize.medium
}

When saving, use name rather than index. The day you reorder the values or insert a new one in the middle, a stored 1 suddenly points to a different value. The text 'medium' stays the same unless you rename the value.

Screen State with an Enum in Flutter

A page being loading, successful, failed or empty is a classic enum example. A switch expression can return a widget directly:

import 'package:flutter/material.dart';

enum LoadState { loading, success, error, empty }

Widget showForState(LoadState state) => switch (state) {
      LoadState.loading => const CircularProgressIndicator(),
      LoadState.success => const Text('Data loaded'),
      LoadState.error => const Text('Something went wrong'),
      LoadState.empty => const Text('No content found'),
    };

This is also where an enum's limit shows: each value is a single constant, so you can't attach the current error message to the "error" state or the loaded list to the "success" state. When states have to carry their own data, Dart 3's sealed classes step in; we cover them in the post on inheritance and polymorphism.

Composition: The "Has-A" Relationship

Composition means a class holds an object of another class as a field. The relationship reads as "a car has an owner," which is why it's called a has-a relationship.

class Person {
  final int id;
  final String name;
  final int age;

  const Person({required this.id, required this.name, required this.age});
}

class Car {
  final Person owner; // Composition: a Car has a Person
  final String model;
  final String plate;

  const Car({required this.owner, required this.model, required this.plate});
}

void main() {
  const ahmet = Person(id: 1233, name: 'Ahmet', age: 25);
  const car = Car(owner: ahmet, model: 'Toyota Corolla', plate: '34 ABC 123');

  print("${car.owner.name}'s car: ${car.model}");
  print('License plate: ${car.plate}');
}

The person data lives in a single class. Tomorrow House, Insurance or Appointment classes can use the same Person; when you want to add a phone number, you change only Person.

Don't Combine late with required

An earlier version of this post declared fields as late int id; and filled them in the constructor with required this.id. Many examples online still use this pattern, and learners copy it constantly. The problem: late means "I'll assign this field later, somewhere outside the constructor." But required this.id already guarantees the field is assigned when the object is created. late does nothing useful here, yet it costs you two things:

  • The field becomes mutable; an assignment like person.id = 5 gets past the compiler.
  • The object can't be created as const.

The right approach is final fields and a const constructor, as above. Save late for cases where you really can't know the value in the constructor (for example, a controller created in initState).

Updating an Immutable Object: copyWith

Once fields are final, the next question is "how do I update the age?" The answer is to create a new object that contains the change:

class Person {
  final int id;
  final String name;
  final int age;

  const Person({required this.id, required this.name, required this.age});

  Person copyWith({String? name, int? age}) => Person(
        id: id,
        name: name ?? this.name,
        age: age ?? this.age,
      );
}

void main() {
  const person = Person(id: 1, name: 'Ahmet', age: 25);
  final nextYear = person.copyWith(age: 26);
  print('${person.age} -> ${nextYear.age}'); // 25 -> 26
}

Flutter's ThemeData.copyWith and TextStyle.copyWith are built on the same idea. Immutable objects also make it easier for state management tools to answer the question "did the data change?"

Enums and Composition Together

In a real model the two concepts sit side by side. An order has a customer (composition), a size (enum) and a list of extras:

enum PizzaSize {
  small(price: 180),
  medium(price: 240),
  large(price: 310);

  const PizzaSize({required this.price});
  final int price;
}

enum Extra {
  cheese(price: 30),
  mushroom(price: 25),
  olive(price: 20);

  const Extra({required this.price});
  final int price;
}

class Person {
  final String name;
  const Person(this.name);
}

class Order {
  final Person customer;
  final PizzaSize size;
  final List<Extra> extras;

  const Order({
    required this.customer,
    required this.size,
    this.extras = const [],
  });

  int get total => extras.fold(size.price, (sum, e) => sum + e.price);
}

void main() {
  const order = Order(
    customer: Person('Elif'),
    size: PizzaSize.medium,
    extras: [Extra.cheese, Extra.mushroom],
  );

  print('${order.customer.name}: ${order.total}'); // Elif: 295
}

Price data lives in the enums, person data in Person, and the calculation logic in Order. Each class has a single responsibility.

Flutter Already Works Through Composition

When you write your own widget in Flutter, you don't extend Card or ListTile; you combine them inside the build method. What we call the widget tree is composition from top to bottom:

import 'package:flutter/material.dart';

class User {
  final String name;
  final String email;
  const User({required this.name, required this.email});
}

class UserCard extends StatelessWidget {
  const UserCard({super.key, required this.user});

  final User user; // Composition: the card has a user

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListTile(
        leading: CircleAvatar(
          child: Text(user.name.isNotEmpty ? user.name[0] : '?'),
        ),
        title: Text(user.name),
        subtitle: Text(user.email),
      ),
    );
  }
}

The only extends here is for StatelessWidget, which is Flutter's widget contract. The view itself is made by combining small widgets.

Composition or Inheritance?

A simple test helps you decide: read the relationship out loud. If "a cat is an animal" sounds natural, inheritance (is-a) fits. If "a car has an owner" sounds natural, composition (has-a) fits. If you end up with an odd sentence like "an order is a person," inheritance is being forced just to reuse fields. When you're unsure, composition stays more flexible, because swapping an owned object is much easier than changing a class hierarchy.

Common Mistakes

  • Storing fixed options as strings; typos never reach the compiler.
  • Adding default to a switch over an enum; you won't be warned when a new value is added.
  • Saving an enum by its index; stored data breaks when the order changes.
  • Making fields late and filling them with required this.field; use final.
  • Reaching for inheritance to reuse code; if the relationship is "has a," use composition.

Next Step

For state data that an enum can't carry and for "is-a" relationships, the next topic is inheritance and polymorphism. To make composition swappable at the behavior level, continue with interfaces and implements.

Resources


Contact:

Frequently Asked Questions

Can Dart enums have fields and methods?

Yes. Since Dart 2.17, enums can declare final fields, a const constructor, getters and methods. You could attach a price to each pizza size and read it as PizzaSize.medium.price.

What happens if I forget an enum value in a switch?

In Dart 3 a switch over an enum has to be exhaustive, so leaving out a value does not compile. When you add a new value to the enum later, the compiler points you to every place that needs updating. Adding a default gives up that check.

Should I store an enum value by name or by index?

Use name. index depends on the order of the values, so old records point to the wrong value once you add a value or reorder them. When reading, use values.byName, or values.asNameMap() to handle unknown names safely.

Should I use composition or inheritance?

If the relationship reads as "is a", inheritance fits; if it reads as "has a", use composition. When you are unsure, composition usually stays more flexible.

Comments