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_unions, sum_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';
}
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.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();
}
}
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.
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:
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 Case
s, 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...
... 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!
I don’t think the title of your article matches the content lol. Just kidding, mainly because I had some doubts after reading the article.
Your article helped me a lot, is there any more related content? Thanks! https://www.binance.com/da-DK/register?ref=V2H9AFPY
Your article helped me a lot, is there any more related content? Thanks!
Thank you for your sharing. I am worried that I lack creative ideas. It is your article that makes me full of hope. Thank you. But, I have a question, can you help me?