2

Sealed Unions in Dart – Never Write an If Statement Again (Kind Of…)

The is operator is a powerful thing. Checking for a subtype of an object is used in many areas, for example in state management with BLoC or Redux. Think about all those events and states. How many times have you forgotten to check for a particular type in an if or a switch statement? There is simply no way, outside of juggling all those different classes in your head, to make sure that every type is being checked for. 

Not anymore! There actually is a way to make you never forget about checking for a certain type. While many other languages have this feature built into them (come on Dart!), for us, Dart developers there is a package for it - sealed_unions.

Adding Dependencies

The sealed_unions package name comes from joining the names of two similar programming concepts - sealed classes and tagged unions. Regardless of how you decide to call it, this package provides a way to make your code more robust. Forgetfulness goes out the window, when the app simply doesn't compile when you forget to check for a type.

First and foremost, let's add sealed_unions to the project. To keep it simple, this tutorial will be written as a bare-bones console app but all of this also works with Flutter and all kinds of state management packages.

pubspec.yaml

dependencies:
  sealed_unions: ^3.0.2+2

Code Without Sealed Unions

We will demonstrate the package on an app state class. Imagine we're building a simple weather forecast app which has three distinct states:

  1.  WeatherInitial - this will prompt the user to search for a weather in a particular city.
  2. WeatherLoading - shows a loading indicator.
  3. WeatherLoaded - shows the temperature in the selected city.

All of these states are subclasses of a WeatherState base class. The usual Dart code for these states without the help of the sealed_unions package would look like this (simplified for brevity):

weather_state.dart

abstract class WeatherState {}

class WeatherInitial extends WeatherState {}

class WeatherLoading extends WeatherState {}

class WeatherLoaded extends WeatherState {
  final int temperature;

  WeatherLoaded(this.temperature);
}

To check exactly which state has, for example, been emitted by a Bloc, we'd have an if statement from which Flutter widgets would be returned.

main.dart

// Imagine this returns Flutter widgets
String widgetBuilder(WeatherState state) {
  if (state is WeatherInitial) {
    return "Some initial widget";
  } else if (state is WeatherLoading) {
    return "Circular progress indicator";
  } else if (state is WeatherLoaded) {
    return "The temperature is ${state.temperature}";
  }
}

Is there any way to enforce that we check for every possible state? Also, if we were to add a WeatherError state, would we be somehow notified that we should handle it in the method above? Nope, we'd have to rely on us remembering things again which is not a good programming practice.

Sealed Unions to the Rescue

How will the code look like once we add unions to the mix? Let's see! We will leave the concrete classes mostly unchanged, but they will no longer extend WeatherState and they will be package private. Yes, we won't need to access these classes from outside of the file!

weather_state.dart

abstract class WeatherState {}

class _WeatherInitial {}

class _WeatherLoading {}

class _WeatherLoaded {
  final int temperature;

  _WeatherLoaded(this.temperature);
}
BLoC package users beware! Make sure to use the equatable for all the states. Otherwise, they will be compared based on referential equality and they won't get emitted.

With the code above, we've just completely broken any relationship between the individual states. Of course, we will immediately correct this by adding the sealed_unions package into the mix. Sadly, as it goes with any library substituting for Dart's lacking functionality, it won't go without a certain amount of boilerplate 😢

In the same file, we will modify the "base" WeatherState class to represent a Union of 3 types.

main.dart

import 'package:sealed_unions/sealed_unions.dart';

// All the possible types of WeatherState have to be specified here
// The package supports Union9 at max (9 types)
class WeatherState
    extends Union3Impl<_WeatherInitial, _WeatherLoading, _WeatherLoaded> {
  // PRIVATE low-level factory
  // Used for instantiating individual "subclasses"
  static final Triplet<_WeatherInitial, _WeatherLoading, _WeatherLoaded>
      _factory =
      const Triplet<_WeatherInitial, _WeatherLoading, _WeatherLoaded>();

  // PRIVATE constructor which takes in the individual weather states
  WeatherState._(
    Union3<_WeatherInitial, _WeatherLoading, _WeatherLoaded> union,
  ) : super(union);

  // PUBLIC factories which hide the complexity from outside classes
  factory WeatherState.initial() =>
      WeatherState._(_factory.first(_WeatherInitial()));

  factory WeatherState.loading() =>
      WeatherState._(_factory.second(_WeatherLoading()));

  factory WeatherState.loaded(int temperature) =>
      WeatherState._(_factory.third(_WeatherLoaded(temperature)));
}

class _WeatherInitial {}

class _WeatherLoading {}

class _WeatherLoaded {
  final int temperature;

  _WeatherLoaded(this.temperature);
}

Yes, it's quite a bit of total boilerplate code, I know. That's the toll we have to pay for Dart's lacking functionality. Even though the individual states no longer inherit from WeatherState, they can still be represented as WeatherStates precisely because of the use of the Union type.

What's the most important to us are the factories initialloading and loaded at the bottom. They are the only way to instantiate the different state classes, as they cannot be instantiated directly from the outside because they are private to the file, as signified by the leading _underscore.

You can make unions of up to 9 types. In such case, you'd subclass Union9Impl class and create a Nonet instead of a Triplet.

A Switch Statement for Unions

Now comes the part we came here for - a special kind of an "if" or "switch" statement which prevents you from forgetting to check for a type. With Unions, such a thing can be accomplished by joining. With Union3 which we're using, we have to provide precisely 3 "cases" of what to return.

main.dart

main(List<String> arguments) {
  // Instantiating the _WeatherLoades state with a factory
  final fakeWidget = widgetBuilder(WeatherState.loaded(42));
}

// Imagine this returns Flutter widgets
String widgetBuilder(WeatherState state) {
  return state.join(
    (initial) => "Some initial widget",
    (loading) => "Circular progress indicator",
    (loaded) => "The temperature is ${loaded.temperature}",
  );
}

There is no way we can handle less than 3 cases - the code simply wouldn't compile. If we were to add a WeatherError state, we'd switch to Union4 (instead of Union3) and we'd immediately get an error until we handled the "error case" in the join method.

What You've Learned

Just because Dart lacks in functionality, compared to other languages such as Kotlin and Swift, doesn't mean we have to settle for less. With the help of the sealed_unions package, we can get the same functionality as is provided by Kotlin's sealed classes or Swift's powerful enums. Granted, it does require a bit of boilerplate and it's up to you to choose between that or erroneous code.

Matej Rešetár
 

Matej is an app developer with a knack for teaching others. If he's not programming, making tutorials or doing other business, he's mostly working out, listening to audiobooks and taking cold showers.

  • Micah says:

    Really appreciate your tutorials. Is there an advantage to storing state in separate classes with sealed unions versus using something like an enum with a switch statement?

  • >