More powerful enums in Dart
The Super Enum package is good for Redux actions and more.
By Craig — 30 May 2020
Coming from the Swift world, I’d missed powerful enum
types in Flutter development. But there’s a pub.dev package for that, and in this article we’ll explore how to use it to clean up your Redux actions.
Why enums?
An enum(erated) type is a great way to express a collection of associated values in a way where the language you’re using will help you write branching code that considers all possible cases. This immediately stands out as something we might want to do with our Redux actions:
enum GameAction {
start,
// ...a bunch of other actions...
finish
}
The advantage of this is that we can switch
over a GameAction
to make sure that our reducer handles all possible actions, and the compiler will complain if we don’t:
// Warning: missing case clause for 'finish'
switch (action) {
case GameAction.start:
return state.copyWith(inProgress: true)
}
It’s nice when the compiler can help you to manage your state!
But there’s a problem. We need to be able to pass parameters along with our actions. For example, for the finish
action we want to send a finalScore
parameter.
Associated values
In Swift, enums are a lot more useful, because cases can have “associated values”:
enum GameAction {
case start
case finish(finalScore: Int)
}
We’re still able to use switch
, only this time we can extract the values we need:
switch action {
case .start:
// ...
case .finish(let finalScore):
// apply finalScore to state
}
It would be great to have a way to do this in Dart. Luckily, there is one!
Super Enum
The Super Enum package hosted here at pub.dev provides just the functionality we need. It allows us to create a supercharged enumerated type with associated values.
It works using code generation. Like me, perhaps you’re not comfortable with the idea of generating large bits of your codebase, but there are plenty of useful packages that work this way (especially for serialisation), so it should be a tool at your disposal.
After following the installation instructions, we’re ready to begin. Don’t forget to install the generator by adding super_enum_generator
to your dev_dependencies
in pubspec.yaml
, and build_runner
if it’s not there already.
Declaring our intention
As with many other packages that rely on build_runner
, we start by defining a private type that tells the generator what we expect to see in the generated type:
import 'package:super_enum/super_enum.dart';
part "game_action.g.dart";
@superEnum
enum _GameAction {
@object
StartGame,
@Data(fields: [
DataField<int>('finalScore'),
])
FinishGame
}
A few things to note:
- we’ve declared a private
enum
_GameAction
(private because of that underscore); - we’ve decorated “regular” enumeration cases with
@object
; - we’re using case names like
StartGame
instead of juststart
—I’ll explain why in the next section; - we’ve decorated cases that have associated values with
@Data(fields: ...)
and then someDataField
instances that declare the name and type of associated value.
Now we run:
flutter pub run build_runner build
Inspecting the generated code
Let’s peek inside the generated file game_action.g.dart
, and see what’s there:
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'game_action.dart';
Fair enough. Don’t touch!
@immutable
abstract class GameAction extends Equatable {
const GameAction(this._type);
factory GameAction.startGame() = StartGame;
factory GameAction.finishGame({@required int finalScore}) = FinishGame;
You can see that for each case we specified a class has been generated that subclasses GameAction
(note: no underscore). The classes themselves are declared at the end of the file, but here you can see factory constructors that produce them. And now you also see why we’ve used i.e. StartGame
as a case name, since littering our namespace with type names like Start
and Finish
is not going to end well. You might consider making your case names even more explicit, like StartGameAction
.
Moving on, we see the switch functionality that we need. If you’ve used Kotlin, it will look familiar:
...
R when<R>(
{@required FutureOr<R> Function(StartGame) startGame,
@required FutureOr<R> Function(FinishGame) finishGame}) {
...
}
R whenOrElse<R>(
{FutureOr<R> Function(StartGame) startGame,
FutureOr<R> Function(FinishGame) finishGame,
@required FutureOr<R> Function(GameAction) orElse}) {
...
}
FutureOr<void> whenPartial(
{FutureOr<void> Function(StartGame) startGame,
FutureOr<void> Function(FinishGame) finishGame}) {
...
}
}
Assuming we have an instance of GameAction
, for example created using GameAction.startGame()
, we have three ways to “switch” over the cases:
- by calling
action.when(startGame: ..., finishGame: ...)
, passing to each parameter a block that accepts the incoming action with its parameters; - by calling
action.whenOrElse(..., orElse: ...)
, optionally including blocks for each action and also including anorElse
block that works likedefault
in a regularswitch
statement; - by calling
action.whenPartial(...)
, optionally including blocks for each statement, with noorElse
.
By using the first of these, we can make sure all cases are handled, and just as before we’ll get a compiler warning if anything is missing!
Putting it into practice
Let’s say that an action has been dispatched to the store:
store.dispatch(GameAction.finishGame(finalScore: 100));
In our reducer, we obtain the next state by calling:
state = (action as GameAction).when(
startGame: (_) => state.copyWith(inProgress: true),
finishGame: (finishGameAction) => state.next(
finalScore: finishGameAction.finalScore,
inProgress: false
)
);
Note that
when
returns the value it got from the block that was called. Hence we can assign the result directly to thestate
value!
This satisfies both of our requirements:
- We got a
finalScore
out of the finish game action and applied it to our state. - The compiler is still helping us. If we’d neglected to handle
startGame
orfinishGame
, we’d get a warning.