Dart Type Promotion
In this article, we'll walk you through a common issue with dart type promotion, showing you why it occurs and some ways to solve it.
6 min read Published: 3 Jan 2024TLDR: inheritance precludes type promotion in some surprising places; I've included an example of how, below.
I have spent a lot of time working on our mobile app this year, which has meant getting familiar with the Flutter framework, and helping other members of the team do the same.
This has been a generally good experience! The mental model for how your UI consists of small, reusable components is not that different from what I was used to working with in React on our web app.
Their documentation is thorough and helpful. Their hot-reloading debug builds enable a pleasant workflow based around a quick feedback loop, while their compiled-to-native release builds do not compromise performance. Their dev tools are intuitive, but still sufficiently robust to handle any challenge I've come across.
I have very few complaints about Flutter, and would recommend it to anyone interested in writing an app for iOS and Android.
Choosing to use Flutter does commit you to working in the dart programming language. Unless you are already familiar with Flutter, you probably haven't used dart much before, so this will be a learning experience.
Fortunately, dart has come a long way over the last few years, and continues to evolve for the better. One way that it has improved massively is sound null-safety, a feature that was introduced as an option in dart 2.12 and is about to become mandatory in dart 3.0.
At this point, I can't imagine working on a non-null-safe dart project; it just makes your life as a developer so much easier.
There is one thing about dart null-safety that is pretty confusing when you first see it though. It confused me when I was learning, it has confused many of my colleagues, and if you've written any amount of null-safe dart code, I'm betting it has confused you.
Type promotion
Let's start with the unsurprising behaviour. If an object's type is narrowed down by some check, then the analyser should recognise that and update its type within the relevant scope. We see this a lot in null-safe code, where we are checking if a nullable object is null, to decide what logic to apply.
If you're curious about this, I'd recommend playing around in dartpad.dev and checking what warnings the analyzer outputs, and what runtime errors you can generate.
For example, consider the programme:
```dart
void main() {
final int? value = 1;
final bool? isEven1 = value?.isEven; // if you omit the ?, the analyzer will complain
if (value != null) {
// in this scope, we know `value` is non-null, so its type is promoted to `int`
final bool isEven2 = value.isEven; // no null-checking required
} else {
// value is definitely null, we can handle it differently if we want to
}
final bool isEven3 = value!.isEven; // if you omit the !, the analyzer will complain again
}
```
We define a variable `value` of type `int?`. We want to call the getter `isEven`, which is a property of the `int` class. It is fine to call `value.isEven` when `value` is non-null, but an error to call `value.isEven` if `value` is null. We can handle this in a few different ways:
1. We can make the method call "null-aware" - `value?.isEven` will call `isEven` if `value` is non-null, otherwise it will short-circuit and return null. The return type is `bool?`.
2. We can assert that the value is non-null - `value!.isEven` will cast `value` to the non-nullable type `int`, allowing us to call `isEven` and return a `bool`. If we do this, and `value` actually is null, then we'll throw a runtime error.
3. We can wrap the call inside a condition that checks if `value` is null. We can handle the case where `value` is null however we want to. In the scope where `value` is non-null, its type has been promoted to `int`, so it is okay to just call `value.isEven`.
This type promotion logic is clever, it also takes into account whether a `return` or `throw` statement makes some code conditionally unreachable, so the following will also work.
```dart
final int? value = 0;
final isEven1 = value!.isEven;
// if `value` were null, we'd have thrown an error, so the code below would be unreachable
final isEven2 = value.isEven; // no null checking required
```
This is all very sensible, and likely behaves as you'd expect if you've used some other strongly-typed language. Most of our team were coming to dart from TypeScript, so this sort of type checking was very familiar, and it all works smoothly enough that you never really had to think about it. But let's now consider a (slightly contrived) example.
A problem
```dart
// imagine we're processing some images, and we want to get the RGB intensities of the pixels
// some image types also handle transparency, so we sometimes need to include an alpha value too
class Pixel {
// our pixel definitely has RGB values, so they're non-nullable
final int r;
final int g;
final int b;
// we might be using an image type that doesn't handle transparency, so let's make the alpha value nullable
final int? alpha;
const Pixel({
required this.r,
required this.g,
required this.b,
this.alpha,
});
// for some reason, we want to sum all the fields, including alpha when it is non-null, and excluding it when it is null.
int sumAllFields() {
if (alpha != null) {
return r + g + b + alpha!; // if we exclude the null-handling, the analyzer gives us a type error here!
} else {
return r + g + b;
}
}
}
```
At this point, we get confused. Why would it be an error not to include the null-handling, as we have checked it isn’t null? We check and double check in case we've made a silly mistake, but we can't see one.
Does it look like the type checking is just broken? You Google it, and maybe you even set out to report an issue to the dart language team. Unless the available explanations have improved since I last looked, you likely find a bunch of terse explanations to the tune of "it isn't safe to type promote a getter", but nothing that walks you through why.
So let's walk through it. The problem is that, inside our class, `alpha` is referring to a field on the class, which it accesses via a getter function. This function is generated for us when we declare the field, but we can choose to define a getter manually if we want to.
This seems harmless; if we redefine the behaviour in the class with a getter, then the type analysis should surely work the same way, just using the updated getter. Unfortunately this is not the case, because we are free to declare another class which extends this one, inheriting all of its methods, but free to override any of them. Let's do that:
```dart
class SadisticPixel extends Pixel{
int alphaCallCount = 0;
SadisticPixel(int r, int g, int b, int? alpha) : super(r: r, g: g, b: b, alpha: alpha);
@override
int? get alpha {
alphaCallCount++;
if (alphaCallCount.isEven) {
return 1;
} else {
return null;
}
}
}
```
So this class inherits `sumAllFields`, but has changed the behaviour of `alpha` in a way that breaks our type-guarding logic. Why would you do this? I have no idea, it seems crazy, but maybe there's some complicated problem where something like this ends up being really useful.
Nevertheless, you are able to do it, so the type system has to take it into account. So what happens when we use this object?
```dart
void main() {
final sadisticPixel = SadisticPixel(1, 2, 3, 4);
// the value of sadisticPixel.alpha will flip between null and 1 each time we call it
print(sadisticPixel.alpha); // null
print(sadisticPixel.alpha); // 1
print(sadisticPixel.alpha); // null
print(sadisticPixel.alpha); // 1
// we get alpha, it's value is null, function returns `r + g + b`
final sum1 = sadisticPixel.sumAllFields();
print(sum1); // 6
// we get alpha, its value is 1, function returns `r + g + b + alpha!`
// but this requires getting alpha again
// when we get alpha the second time, its value is null, so `alpha!` will throw a runtime error
final sum2 = sadisticPixel.sumAllFields(); // Uncaught TypeError
}
```
This is why we had to include the null handling in `Pixel.sumAllFields`. Even though it seems entirely type safe when we see the implementation in `Pixel`, it can't guarantee that it would be safe in all possible classes that extend `Pixel`, and therefore does not promote the type from `int?` to `int`.
A Solution
You could just include null handling everywhere in your class methods, and in our simple example above that seems like a reasonable idea, but in practice you will probably have more complicated logic than this where adding explicit null-handling everywhere will quickly get tedious.
A neat solution in this case is to introduce a local variable. Consider this example:
```dart
class Example {
final int? value;
const Example(this.value);
void function1() {
if (value != null) {
// logic that needs to keep checking if value is null, because we are calling the getter repeatedly
}
}
void function2() {
final localValue = value;
if (localValue != null) {
// logic can use localValue safely without checking if it's null
// localValue can be type promoted because it isn't calling an overridable getter, it's just accessing a value
}
}
```
Hopefully this has helped to demystify this strange behaviour. It's worth noting that this issue is not limited to the promotion of nullable types, I've just focused on the nullable case because it's the one I've seen come up most often, but it applies to any narrowing type promotion.
For example, you could get the same behaviour if you were casting a `number` to an `int`. The reasoning is exactly the same no matter what the narrowing type conversion is though.
More from Tech Blog
View AllGlean hack week - developing a Minimal Loveable Feature
Our Glean Engineering team recently took time out of their busy schedules to run a hack week, designed to build innovative solutions and unleash their creativity. Engineering Manager, Mala Benn, is here to tell us how they got on.
Exploratory Testing at Glean
Zaryoon shares what Exploratory Testing is, and how it can be implemented in your tech teams to gain a deeper understanding of how your features work.
Thinking Fast & Slow
Lynn shares how the Engineering team are applying the principles of "Thinking Fast & Slow" to their everyday work!