Designing with Types in Dart & Flutter – Sum Types Tutorial

Handling poorly documented edge cases is tough. Using nulls to represent "no value" and then having to check for nulls all over the place is bad. Remembering all the possible subtypes of a base class is hard. All in all, relying on you "remembering things" is a recipe for disaster.

That's why you should model your domain in such a way that wrongly written code doesn't even compile. That's right, you can actually prevent many run-time crashes and get notified about errors in the code editor before hitting the run button.

What we will build

We're going to demonstrate all of the above by building a small part of a fake CRM (customer relationship management)  responsible for sending out physical letters. We support only customers from the USA and from Canada. As you know, one component of a postal address is the state (e.g. Florida) for US, or the province (e.g. Ontario) for Canada. It's precisely on the state or province, where you'll learn how to design with types.

We will move from the most naive (and sadly common) implementation to the most robust one. The robust implementation will require us to use some sort of a sum type (a.k.a. sealed classes in Kotlin or powerful enums in Swift).

Adding dependencies

As of now, Dart doesn't support such types natively, but we can add this functionality with a package. A while ago, I made a tutorial on the Sealed Unions package and although we could use it even for this project, there's a better package - sum_types.​​

Compared to sealed_unionssum_types doesn't have any unnecessary boilerplate and it leverages code generation. It's a very powerful package which can do many things, including better domain modeling which is what we're after.​​​​

pubspec.yaml

dependencies:
  sum_types: ^0.1.5+1

dev_dependencies:
  sum_types_generator: ^0.1.5+1
  build_runner:

The WORST approach 😰

We know we want to store some postal address information, most importantly the US state or Canadian province. Thus, we're going to create a PostalInfo class. Also, let's be completely naive and represent everything as a String.

my_types.dart

class PostalInfo {
  final String street;
  // ...additional address fields here...
  final String stateOrProvince;

  PostalInfo({
    @required this.street,
    @required this.stateOrProvince,
  })  : assert(street != null),
        assert(stateOrProvince != null);

  @override
  String toString() => '$street, $stateOrProvince';
}

It's immediately apparent that the code above is totally unsafe. The stateOrProvince field can be assigned any sort of a value. You can see how bad this is in the code below:

my_types.dart

void run() {
  // Correct
  final postalInfoUS = PostalInfo(
    street: 'Some US Street',
    stateOrProvince: 'FL',
  );
  print(postalInfoUS);

  // Correct
  final postalInfoCA = PostalInfo(
    street: 'Some Canadian Street',
    stateOrProvince: 'ON',
  );
  print(postalInfoCA);

  // Wrong, but still compiles and runs!
  final postalInfoErroneous = PostalInfo(
    street: 'Some XYZ state or province Street',
    stateOrProvince: 'XYZ',
  );
  print(postalInfoErroneous);

  // Wrong, compiles but throws a runtime error.
  final postalInfoErroneous2 = PostalInfo(
    street: 'Some non-specified state or province Street',
    // Not assigning stateOrProvince despite the analyzer warning
  );
  print(postalInfoErroneous2);
}

Sure, we could provide more asserts to make the PostalInfo instance where we assign 'XYZ' as the state or province throw a runtime error. Is that a practical use of our time though? I think we can do better...

Better, yet bad approach 😐

What can we do differently to make the code more expressive and safe? Surely, stateOrProvince has no place in being a simple String. Let's pull it into a separate StateOrProvince class and create enum​​s for 

my_types.dart

// Only Florida and Ontario for brevity
enum UsState { florida }
enum CaProvince { ontario }

class StateOrProvince {
  final UsState usState;
  final CaProvince caProvince;

  StateOrProvince({
    this.usState,
    this.caProvince,
  }) : assert(usState != null || caProvince != null);

  @override
  String toString() => usState != null ? '$usState' : '$caProvince';
}
You could say that StateOrProvince is a wannabe sum type. It's still just a regular class with two fields, but it in fact represents only "one thing" as you can see in the toString()​​ implementation. The client code doesn't care if it's a state or a province - it just wants the value.

We will create an actual sum type in the robust third approach.

Now we can change the PostalInfo class.

my_types.dart

class PostalInfo {
  final String street;
  final StateOrProvince stateOrProvince;

  PostalInfo({
    @required this.street,
    @required this.stateOrProvince,
  })  : assert(street != null),
        assert(stateOrProvince != null);

  @override
  String toString() => '$street, $stateOrProvince';
}

When we now modify the run() method to work with the current approach, we won't be able to assign 'XYZ' to be the state or province. Still, however, we are able to leave the StateOrProvince object "blank" with nulls, which will result in a runtime crash.

my_types.dart

void run() {
  // Correct
  final postalInfoUS = PostalInfo(
    street: 'Some US Street',
    stateOrProvince: StateOrProvince(usState: UsState.florida),
  );
  print(postalInfoUS);

  // Correct
  final postalInfoCA = PostalInfo(
    street: 'Some Canadian Street',
    stateOrProvince: StateOrProvince(caProvince: CaProvince.ontario),
  );
  print(postalInfoCA);

  //* GOOD - doesn't compile.
  final postalInfoErroneous = PostalInfo(
    street: 'Some XYZ state or province Street',
    // There's no way we can provide a wrong state or province.
    stateOrProvince: StateOrProvince(caProvince: 'XYZ'), // <-- Error here
  );
  print(postalInfoErroneous);

  //! BAD - does compile and crashes the app because of the non-null assert
  final postalInfoErroneous2 = PostalInfo(
    street: 'Some non-specified state or province Street',
    // We can still leave out the state or province completely 😐
    stateOrProvince: StateOrProvince(),
  );
  print(postalInfoErroneous2);
}

Sending letters

There's another aspect of the CRM app which we should to consider and that's sending letters. Our customers reside either in the United States or in Canada. Of course, there are different processes which go into sending the letter depending on the country. Let's add 3 simple placeholder methods for printing a "physical" letter, sending a letter to the USA, and finally, sending a letter to Canada

my_types.dart

void printLetter(PostalInfo postalInfo) {
  // Imagine this prints a physical letter...
  print(
    '''
*******
${postalInfo.street}
${postalInfo.stateOrProvince}

Yours truly,
Reso Coder
*******
''',
  );
}

void shipLetterUSPS() {
  print('Shipping to United States\n\n');
}

void shipLetterCanadaPost() {
  print('Shipping to Canada\n\n');
}

Inside a sendLetter() method which we're about to create, we have to differentiate between US and Canadian letter, so that we can ship them to the proper country. This means we have to set up an if statement checking which state or province is currently null.

my_types.dart

void sendLetter(PostalInfo postalInfo) {
  //! Getting lost in nulls
  if (postalInfo.stateOrProvince.usState != null) {
    // Do some automatic physical printing and ship the letter...
    printLetter(postalInfo);
    shipLetterUSPS();
  }
  else {
    printLetter(postalInfo);
    shipLetterCanadaPost();
  }
}
You'd probably not determine the country from a stateOrProvince, but you'd have a separate country field instead. This is an example app, after all.

The question is - can we just use an else clause to handle letters heading over to Canada? In this case, we can - we don't support any more countries, so non-US letters can be only Canadian. There are still quite a few objections which should be raised against this code:

1. Can't the caProvince field be null?

In a few months when the requirements change and we come back to this code, we'll be probably scratching our head with this question in our mind. Although we don't check for it in the else clause, the caProvince field indeed cannot be null because of the assert we have in the StateOrProvince class.

class StateOrProvince {
  ...
  StateOrProvince({
    this.usState,
    this.caProvince,
  }) : assert(usState != null || caProvince != null);
  ...
}

The chances of us remembering this in a few months are slim to none and we will be forced to search through the codebase for this assertion.

2. What if we add another supported country?

Awesome! Business is expanding to the United Kingdom. Apart from code changes in other places, we will again need to remember to update the if statement with an additional UK else and change the Canada clause to else if. Again, we won't get any compile-time errors in the code after adding UK as a supported country. We'll just need to juggle all of this in our head which is a recipe for a buggy code.

Don't implicitly remember things. Instead, make them explicit in the code in a way which produces compile-time errors. That's what we're going to do next.

The BEST approach 🤩

The culprit which needs fixing is the StateOrProvince class. Although from the client code point of view it represents just one "thing", it still just holds two separate fields which can be null. This causes all the problems mentioned above.

How can we make two distinct cases (US state and Canadian province) be represented by one type? The first thing that comes to mind is inheritance. That, however, would be clunky and we still wouldn't get any compile-time errors to guide use. Instead, we're going to use sum_types!

They will ensure that we cannot possibly input any wrong values to the stateOrProvince field of the PostalInfo class. Elaborately said:

Sum types make illegal state unrepresentable.

Also, should we add support for UK customers, our code for sending letters will break and that's a good thing since it will force us to update it. A CRM software surely shouldn't "forget" to send letters to the United Kingdom.

Using sum_types

The sum_types package works​​ by code generation. This means, we have to add a part directive to the top of the file. Creating a sum type is pretty self-explanatory. Instead of holding enum values in fields, a sum type holds them in Cases, which are named in this case.

my_types.dart

import 'package:sum_types/sum_types.dart';

part 'my_types.g.dart';

enum UsState { florida }
enum CaProvince { ontario }

@SumType([
  Case<UsState>(name: 'usState'),
  Case<CaProvince>(name: 'caProvince'),
])
mixin _StateOrProvince implements _StateOrProvinceBase {}

The @SumType annotation has to be used on a package-private mixin which implements an almost same-named abstract class suffixed with "Base". That's as little boilerplate as it can get for generated classes in Dart. After generating code with the following command...

flutter packages pub run build_runner watch

... we can now use the power of sum types! Setting the state or province to 'XYZ' is now impossible and similarly, we can't leave it blank without getting a compile-time error.

Instantiating the generated StateOrProvince sum type class is done with named constructors. Their names are the same as those provided to the Case constructor above.

my_types.dart

void run() {
  // Correct
  final postalInfoUS = PostalInfo(
    street: 'Some US Street',
    stateOrProvince: StateOrProvince.usState(UsState.florida),
  );
  print(postalInfoUS);

  // Correct
  final postalInfoCA = PostalInfo(
    street: 'Some Canadian Street',
    stateOrProvince: StateOrProvince.caProvince(CaProvince.ontario),
  );
  print(postalInfoCA);

  // Compile-time error
  final postalInfoErroneous = PostalInfo(
    street: 'Some XYZ state or province Street',
    stateOrProvince: StateOrProvince.caProvince('XYZ'), // <-- Still impossible
  );

  // Compile-time error
  final postalInfoErroneous2 = PostalInfo(
    street: 'Some non-specified state or province Street',
    stateOrProvince: StateOrProvince.usState(), // <-- Can't leave this blank
  );
}

Sending letters with sum_types

The sendLetter() method is now kind of broken. There are no errors, but that's just because of the dumb way that @protected members are handled in Dart. So even though the current implementation of the method will run just fine...

my_types.dart

void sendLetter(PostalInfo postalInfo) {
  // usState is marked as @protected in the generated class
  if (postalInfo.stateOrProvince.usState != null) {
    printLetter(postalInfo);
    shipLetterUSPS();
  } else {
    printLetter(postalInfo);
    shipLetterCanadaPost();
  }
}

... we still lose out on all the benefits of using a sum type, like not having to remember things and being notified with compile-time errors when we add support for United Kingdom customers.

The sum_types package grants us an iswitch() method which provides amazing tooling. We are forced to handle all the cases and as soon as we add a new case (UK), it will break, again forcing us to handle even the new case.

my_types.dart

void sendLetter(PostalInfo postalInfo) {
  // Forced to handle all the cases
  postalInfo.stateOrProvince.iswitch(
    // The actual state/province is passed into the function
    usState: (UsState state) {
      printLetter(postalInfo);
      shipLetterUSPS();
    },
    // We don't need this parameter, so we can ignore it with _
    caProvince: (_) {
      printLetter(postalInfo);
      shipLetterCanadaPost();
    },
  );
}

Custom methods in sum types

There's a minor issue with the current StateOrProvince class. While it wasn't a sum type, we overrode the toString() method to do the following:

  @override
  String toString() => usState != null ? '$usState' : '$caProvince';

This made the following, quite nice looking output: 'UsState.florida'​.

Now, however, converting the StateOrProvince sum type to string triggers the default toString() implementation located in the generated class. The output now looks like this: 'StateOrProvince.usState(UsState.florida)'. It's not horrible, but let's make it look the same as before, while we weren't using a sum type.

While technically we could modify the generated toString() method, any of our changes would be lost as soon as we triggered another code generation. Therefore, we have to add code to the non-generated _StateOrProvince mixin. Sadly, code inside a mixin cannot override instance methods, so we have to define a custom toFormattedString() method.

my_types.dart

@SumType([
  Case<UsState>(name: 'usState'),
  Case<CaProvince>(name: 'caProvince'),
])
mixin _StateOrProvince implements _StateOrProvinceBase {
  String toFormattedString() => iswitch(
        usState: (state) => '$state',
        caProvince: (province) => '$province',
      );
}

By having a non-standard toString() method, we lose out on the beautiful interpolated strings integration. To use our custom method, we have to call it manually...

my_types.dart

class PostalInfo {
  ...
  @override
  String toString() => '$street, ${stateOrProvince.toFormattedString()}';
}

...

void printLetter(PostalInfo postalInfo) {
  print(
    '''
*******
${postalInfo.street}
${postalInfo.stateOrProvince.toFormattedString()}

Yours truly,
Reso Coder
*******
''',
  );
}

Conclusion

Using sum types not only saves you from run-time crashes and other headaches, but it also saves you time. After all, writing all of those crazy null checks doesn't get handled by itself. On the other hand, the sum type 'substitute' for an if or switch statement, the iswitch() method, immediately tells you which cases you should check for.

If you like the sum_types package, please, show the author some support by giving the sum_types repository a star on Git Hub!

Icons and other attribution GOES HERE
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.

>