Dart Enum and Composition: How to Organize Your Code
Last updated:
7 min read

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
extendsanother class, because it automatically extendsEnum. It can useimplementsandwith, though. index,hashCodeand==can't be overridden, and no member may be namedvalues.
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 = 5gets 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
defaultto 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
lateand filling them withrequired this.field; usefinal. - 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.
Related Posts
Dart Interfaces and implements: Contracts for Classes
Contracts with implements in Dart, Dart 3's interface class and abstract interface class, how mixins with differ, and an example of testable code.
Dart Inheritance and Polymorphism: A Practical Guide
Inheritance in Dart with extends, super and @override, polymorphism, abstract classes, safe type checks, and Dart 3's sealed, final and base class modifiers.
Flutter Camera Without Forcing Orientation: Three Bugs and Their Fixes
A rotated preview, a freeze when recording starts and a dead camera: three Flutter camera bugs from Recapday and the code that fixed each one.