Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions rfc/710.0000-style-guide-enums.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
type: rfc
rfc: '710.0000'
title: Style guide changes for enums
description: Updates the Flutter style guide and breaking change policy for enums and enum-like classes.
status: draft
created: 2026-10-08T00:00:00Z
updated: 2026-10-08T18:55:00Z
tags:
- 710-style-guide
- 030-breaking-changes
authors:
- https://github.com/chunhtai
---

# RFC 710.0000: Style guide changes for enums

## Overview

This document proposes updating the [Flutter repository style guide](https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md) and [Flutter breaking change policy](https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes) to establish conventions for public enums and enum-like classes.

* For public APIs, use a standard `enum` when the set of values is closed by nature or when consumers must handle every value exhaustively; otherwise, prefer an enum-like class with `static const` fields.
* Adding a value to a public `enum` (or a new subtype to a public `sealed class`) breaks exhaustive switches and requires going through the breaking change process.
* These rules apply to new APIs; migrating existing public `enum` declarations is left to case-by-case evaluation.

## Context

Inside the Flutter codebase, requiring exhaustive `switch` statements and expressions over `enum` values (without `default` branches) is intentional and remains unchanged. However, when external callers or `flutter/packages` switch exhaustively over a public `enum`, adding a new value breaks downstream compilation and rolls.

Without a library-level convention in the style guide, public APIs in the framework are inconsistent:

* `TargetPlatform` and `AppLifecycleState` are `enum` declarations even though platforms and OS lifecycle states grow over time. Adding new values breaks callers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have an issue for these?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we haven't added one yet for a long time, but mostly due to we stop adding more platform and prioritize mobile. but they are a lot likely to expand if we do so, and I imagine it will break a bunch of people

@loic-sharma loic-sharma Oct 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For pluggable platforms, I plan to deprecate TargetPlatform in favor of a non-exhaustive enum that's open for extension by external code. Design TBD though :)

* `TextInputType` and `LogicalKeyboardKey` use classes with `static const` fields to allow non-breaking additions, but the style guide does not document when or how to use this pattern.

## Proposal

### Non-public enums

Library-private (`_`) and `@internal` enums must use a standard `enum` declaration so framework `switch` statements and expressions are exhaustively checked.

### Public APIs

Use the following decision order to choose between a standard `enum` and an enum-like class:

1. If the set of values is closed by nature (such as `Axis` with `horizontal` and `vertical`, `VerticalDirection` with `up` and `down`, or `GrowthDirection`), use a standard `enum`.
2. Otherwise, if consumers receiving the value from a callback, builder, or state object must handle every case because no safe fallback exists (such as `ConnectionState` in `FutureBuilder` and `StreamBuilder`, or `TextSelectionHandleType` in `TextSelectionControls.buildHandle`), use a standard `enum`.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rewrote this part to only focus on enum giving out to the developer to consume, otherwise they should all be enum-like. Let me know what you think @loic-sharma , @LongCatIsLooong , @justinmc

@chunhtai chunhtai Oct 9, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This however has risk for developer override class though. If we want to be more strict, they we will likely need to go back to broader set like layout related enum such as MainAxisAlignment.

@LongCatIsLooong LongCatIsLooong Oct 20, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to combine bullet point 1 and 2 since they seem to be saying the same thing? Basically something like

Use closed enum in public API only if

  • clients only produce enum values and can never consume the enum value (i.e., the framework is the sole consumer, so we can't let the client send us something we don't understand).
  • you have reasons to believe that the exhaustiveness needs to be part to the API contract (e.g., states of a state machine).

I'm for prefer exposing non-exhaustive enums when in doubt: if we use a final class then later decides enum is more appropriate, making that change can't be source breaking it seems?

3. Otherwise, prefer an enum-like class with `static const` values (such as `TextInputType` and `LogicalKeyboardKey`; existing enums like `TargetPlatform` and `AppLifecycleState` also fit this category).

#### Standard `enum` (exhaustive)

* Provides compile-time exhaustiveness checking for `switch` statements and expressions.
* Adding a value to a public `enum` is a breaking change and requires going through the breaking change process. The same rule applies to adding a subtype to a public `sealed class`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not changing the breaking change policy right? So the wording here may have to change a bit to be put in the style guide.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not the exact wording. will rephrase when i actually put this in part of styleguide.

I was thinking to have a section in breaking change to talk about what we are not consider breaking change, which will include this and package private. WDYT

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking to have a section in breaking change to talk about what we are not consider breaking change, which will include this and package private

Do you mean adding a new case to a closed enum? If we add a new value to TextDirection almost every app breaks so I doubt we will be able to get away with changes like that even if we have that in our breaking change policy.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean adding a new case to a closed enum

I think we all agree on this, not sure if we need a written rule for this case.

I am thinking adding a clause like: Adding an enum to a open enum class is not a breaking change.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding an enum to a open enum class is not a breaking change.

Hmm are we looking to change the breaking change definition in the breaking change policy? I mean today in dart I can't think of a way to introduce a static member to a final class from outside of the class, but I think the dart team is looking to allow introducing static members to an existing namespace (via extension methods), when that's added to the language, adding a new "case" can introduce name conflicts in client code and that should be a breaking change IMO.


#### Enum-like class with `static const` values (non-exhaustive)

Implement as an `@immutable` `final` class with `static const` fields

* Adding a `static const` field is not a breaking change.
* Marking the class `final` to prevent external packages from extending or implementing it.
* Use a private `const` constructor when only Flutter defines valid values (such as a platform or lifecycle state).
* Include a distinguishing field to prevent Dart `const` canonicalization from collapsing distinct constants into a single instance. Choose the field type based on the domain of the enum (such as `int` for hardware key codes); otherwise, prefer a `String name` field with the same value as the property name by default.

##### Example: closed to external instantiation

When only Flutter defines valid instances, use a private `const` constructor:

```dart
@immutable
final class MyEnum {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@LongCatIsLooong

I can't find a good use case for sealed class without more work. let me know you have other ideas in mind

@LongCatIsLooong LongCatIsLooong Oct 8, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There aren't that many sealed classes in the framework yet if I recall, but there are several classes that could have been made sealed, from the top of my mind:

The Element class where an Element is either a ComponentElement or a RenderObjectElement
The TextSelection class where a selection is either collapsed (TextPosition) or a non-collapsed (uhh we don't have that subclass, but maybe NonCollapsedTextSelection).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's one I wrote... that probably shouldn't have been a sealed class based on the breaking changes addressed in this document. https://github.com/flutter/flutter/blob/55f060daa5c8fa9c7c7aefa579daa2e59fbe15bf/packages/flutter/lib/src/widgets/system_context_menu.dart#L240

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sealed class can't have a public constructor, so it seems like the compiler will ask for exhaustive match

sealed class MyEnum {
  // this is private by default
  const MyEnum();
  static const MyEnum foo = MyEnum2(0);
  static const MyEnum bar = MyEnum2(1);

  static const List<MyEnum> values = <MyEnum>[foo, bar];
}

class MyEnum2 extends MyEnum {
  const MyEnum2(this.index);
  final int index;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think extension types are cut out for this: not subtypeable, no need for hashCode and == boilerplates, has value semantics if the underlying type is a value type. If you rewrite the MyEnum example:

extension type const MyEnum._(int index) { 
  static const MyEnum foo = MyEnum._(0);
}

@loic-sharma loic-sharma Oct 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another benefit of extension types is that can be used in a const set or as the key in a const map. However, I'd recommend against extension types because they negatively impact the debugging experience: dart-lang/sdk#53461

Demo of debugging experience of extension types...
void main(List<String> arguments) {
  var e1 = E1.foo; // e1 = E1 (E1.foo)
  var e2 = E2.foo; // e2 = 1
  var e3 = E3.foo; // e3 = E3 (E3.foo)
}

enum E1 { foo, bar }

extension type const E2._(int index) {
  static const E2 foo = E2._(0);
  static const E2 bar = E2._(1);

  // Extension types can't declare members with the same name as a member declared by 'Object'.
  // Try specifying a different name for the member.
  // dart(extension_type_declares_member_of_object)
  // @override
  // String toString() => 'E2.$value';
}

@immutable
final class const E3._(final int index) {
  static const E3 foo = E3._(0);
  static const E3 bar = E3._(1);

  static const List<String> _names = ['foo', 'bar'];

  @override
  bool operator ==(Object other) {
    return other is E3 && other.index == index;
  }

  @override
  int get hashCode => index.hashCode;

  @override
  String toString() => 'E3.${_names[index]}';
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extention type can cause some trouble for equality and toString for debugging, for example MyEnum(1) == MyAnotherEnum(1) will be true

@LongCatIsLooong LongCatIsLooong Oct 20, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah unfortunately you won't be able to override toString. Also I just realized another library can easily implement your extension type (so if you can't turn that extension type into an enum without breaking those implementers)

const MyEnum._(this.name);

final String name;

static const MyEnum foo = MyEnum._('foo');
static const MyEnum bar = MyEnum._('bar');

@override
String toString() {
return 'MyEnum.$name';
}

@override

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you only have a private constructor, you can avoid overriding equals and hashcode. This will enable using the values as keys in const maps and sets.

Even with adding new const instances latter equals and hashcode will work as expected because there is only ever the const instances.

The only thing that you cannot do is later decide to make the constructor public. Because then equals and hashcode must be overriden which makes the values no fit in const maps as keys.

Is the reason that you override hashcode and equals so that you can decide later to make an enum extensible? If, yes, document this in the RFC.

bool operator ==(Object other) {
return other.name == name;
}

@override
int get hashCode => name.hashCode;
}
```

##### Example: open to external instantiation

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


When callers may construct custom instances, use a public `const` constructor:

```dart
@immutable
final class MyEnum {
const MyEnum(this.name);

final String name;

static const MyEnum foo = MyEnum('foo');
static const MyEnum bar = MyEnum('bar');

@override
String toString() {
return 'MyEnum.$name';
}

@override

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it is useful to explicitly state that overriding equals and hashcode is important for extensibility and pattern matching.

If you don't override them pattern matching doesn't work for values created by third parties.

bool operator ==(Object other) {
return other.name == name;
}

@override
int get hashCode => name.hashCode;
}
```

### Scope and migration

These conventions apply to new public APIs. Existing public `enum` declarations that represent open sets (such as `TargetPlatform` and `AppLifecycleState`) are not migrated by this proposal and should be evaluated case-by-case, such as when adding a new value already requires a breaking change.

@LongCatIsLooong LongCatIsLooong Oct 8, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should AppleLifecycleState have been made an "open" enum? I think there are cases developers / plugin authors want to handle that exhaustively, and when a new case is added to AppleLifecycleState they want to be notified when compiling against newer framework versions?

@chunhtai chunhtai Oct 9, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be open. most people care about resume and pause only. IIRC the latest of the enum, detached, is added for very specific use case (for warmup). I think if we have to consider every possible usage. all the enum will need to be closed

@LongCatIsLooong LongCatIsLooong Oct 20, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's true but I think in general I would prefer state machine states to be exhaustive, as the state transition is usually an important part of the contract.
What if I introduce a new state (because, say, Apple introduced a new state), and the contract becomes "after pause, you may either enter this new state or resumed", so if a client fails to handle this new case it will fail to update it's own state and gets stuck in a paused state?

Loading