After a teammate inserted a new value into a Dart OrderStatus enum, orders saved on devices reloaded with the wrong status; what went wrong, and how should enums be persisted?
answer
- position shifted by one
- index follows declaration order
- names survive reordering, not renaming
- an explicit stable code field
- fallback value for unknown codes
basics
~20 sThe app stored index, which is only the declaration position, so inserting a value shifted every later index. Persist a stable identifier instead, such as name or an explicit code field, and map unknown or missing codes to a fallback value.
solid answer
~40 s`index` is the zero-based position in the declaration, so inserting `packed` between `pending` and `shipped` turns every stored `1` from `shipped` into `packed` without any error. Persisting `name` survives reordering and insertion, but a rename of the identifier breaks stored data, and `values.byName` throws an `ArgumentError` for a name the current build does not know, such as one written by a newer app version or a server. The robust pattern is an enhanced enum with an explicit, never-changing `code` field, a static lookup map built from `values`, and a fallback such as an `unknown` value for unrecognised input. Store and send `code`; keep `index` for in-memory ordering only. Test the mapping so that a reordering or a rename that would change stored data fails a test instead of shipping.
code
dart · 25 linesenum OrderStatus {
pending('pending'),
packed('packed'),
shipped('shipped'),
delivered('delivered'),
cancelled('cancelled'),
unknown('unknown');
const OrderStatus(this.code);
/// Stored and sent over the wire. Never change a shipped code.
final String code;
static final Map<String, OrderStatus> _byCode = {
for (final status in values) status.code: status,
};
static OrderStatus fromCode(String? code) => _byCode[code] ?? unknown;
}
void main() {
print(OrderStatus.fromCode('shipped')); // OrderStatus.shipped
print(OrderStatus.fromCode('returned')); // OrderStatus.unknown
print(OrderStatus.fromCode(null)); // OrderStatus.unknown
}go deeper
Recall that index is just the declaration position and changes when values are inserted or reordered.
Explain why name survives reordering but not renaming, and why byName throws for unknown input while asNameMap returns null.
Diagnose the silent index shift from device data, introduce stable codes with a fallback, and plan a migration for already-stored indices.
Treat every enum that crosses a process boundary as a versioned contract, with owners, tests and a migration policy.
## The failure An app stores each order's status in local storage as an integer: ```dart prefs.setInt('status', order.status.index); ``` The enum was `pending, shipped, delivered, cancelled`. A teammate inserts `packed` after `pending`. On the next release: | Stored int | Meant | Now decodes as | |---|---|---| | 0 | `pending` | `pending` | | 1 | `shipped` | `packed` | | 2 | `delivered` | `shipped` | | 3 | `cancelled` | `delivered` | Nothing throws. Delivered orders now show as shipped, and cancelled ones as delivered. The bug surfaces only on devices that saved data with the old build, which makes it slow to diagnose. ## Why `index` is the wrong identifier `index` is defined as the value's **zero-based position in the declaration**; it is also the position in `values`. It is excellent for in-memory work, such as ordering with `Enum.compareByIndex` or indexing an array, and it is meaningless outside the build that produced it. Any of these changes it: - inserting a value anywhere but at the end; - reordering values, for example alphabetically in a cleanup; - deleting a value. ## Is `name` enough? Persisting `status.name` survives insertion and reordering, which is a big improvement. Two gaps remain: 1. **Renames.** Changing `inTransit` to `shipped` in code changes the stored identifier. A refactoring tool will happily do it. 2. **Unknown names.** `OrderStatus.values.byName(raw)` **throws an `ArgumentError`** when no value matches. Data written by a **newer** app version, or a status a server added, then crashes an older app. `values.asNameMap()[raw]` returns `null` instead and lets you choose a fallback. ## The robust pattern: an explicit code Decouple the stored identifier from the Dart identifier: 1. Give the enum a `final String code` field that is **never changed** once shipped. 2. Build a static lookup map from `values` once. 3. Map unknown or missing codes to a **fallback value**, such as `unknown`, rather than throwing. 4. Store and send `code` only. Now the Dart identifiers can be renamed and reordered freely, new values can be added anywhere, and an older build meeting a newer code degrades to `unknown` instead of crashing. ## Guarding it - A unit test that asserts the full `code` list, in a fixed expected form, catches an accidental code change in review. - A test that every value's `code` is unique catches copy-paste mistakes; the lookup map would otherwise silently keep only the last duplicate. - Treat a change to a persisted code as a **data migration**, with an explicit mapping for stored values. ## Diagnosing it after the fact When users report statuses that look shifted by one, the clues are consistent: 1. Only data saved **before** an upgrade is wrong; data created after it is fine. 2. The wrong value is always the **neighbour** in declaration order. 3. The version control history shows a value inserted or a list reordered in that release. Reading the stored raw value on an affected device, or in a support log, confirms it: the integer is right for the old order and wrong for the new one. ## Where the same rule applies - **Local storage and databases**: store codes or names, never indices. - **Network payloads**: agree codes with the backend; serialization libraries let you map enum values to explicit wire values, which is the same idea. - **Deep links and analytics events**: the identifier outlives any single build, so it needs to be stable too. ## Common mistakes - Assuming "we only ever append" will hold; one alphabetical reorder breaks it. - Using `byName` on stored or remote data and turning a new server status into a crash. - Using `toString()` as the stored form; it includes the type name and changes if an enhanced enum overrides it.
- Data already saved as indices is on users' devices. How do you migrate it safely?Freeze the old order as an explicit table, for example `const legacyByIndex = ['pending', 'shipped', 'delivered', 'cancelled']`, and on first launch of the new version read each stored int, translate it through that table to a code, write the code back and mark the migration done. Never decode old data through the current `values` list, whose order has already changed.
- Why does a unique code per value matter for the lookup map?The map is built with the code as key. If two values share a code, the later entry overwrites the earlier one without any error, so one value can never be decoded. A test asserting that the set of codes has the same length as `values` catches this.
saying these in an interview costs you the question
- Enum index is stable as long as nobody deletes a value.
- Persisting name is fully safe, including across renames.
- values.byName quietly returns a default for unknown names.
- toString() is a good stored form for enum values.
- Reordering enum values only affects how they sort.