Future<void> vs Future<Null>, what’s the difference?
One of the nice upgrades we were able to do in Dart 2 (in addition to better static checking, runtime type safety, optional new/const…
One of the nice upgrades we were able to do in
Dart 2
(in addition to better static checking, runtime type safety,
optional new/const, core library improvements, and more) is
to formalize
void in a way that’s both more useful and less
error-prone. This is especially clean in async programming
where you can write
Future<void> for asynchronous
functions that don’t return an answer when their work
completes. Before this you probably used
Future<Null>, so common questions
we get asked are: What’s the difference between
Future<void> and
Future<Null>? Which should I use,
and when?
Since my goal here is to be useful, I’m going to start with a TLDR.
TL;DR: You want to use the void type 99.99% of the time.
I also recommend you turn on two void-related
lint rules
in your projects, right now:
-
prefer_void_to_null: Helps keep you from slipping back into the habit of typing the mostly-antiquated
Null. -
void_checks: Gives you voidness semantics that you may find more intuitive, even though they are not strictly necessary for safe code.
The rest of this article is not light reading. It is a combination of history, edge cases, and type theory. What I’m about to write is like trying to explain a Monad, but I’ll do my best, and I hope you follow along for the ride.
Quick Future<Null> trivia
#To demonstrate the value you might get out of reading this post, I pose a trivia question for you.
Say we limit ourselves to two Futures — a
Future<User> and a
Future<Null>. What do the
following lines of code do?
// does this fail in your editor?
Future<User> f1 = Future<Null>.value(null);
// does this fail at runtime?
Future<Null> f2 = Future<User>.value(0);
Does it help if we switch to awaits?
User u = await Future<Null>.value(null);
Null n = await Future<User>.value(0);
pauses for dramatic effect
The answer is that none of these four lines will show any
errors in your editor, unless you have disabled implicit
downcasts. That’s right, taking a
Future<Null> and pretending it’s
something it isn’t, like
Future<User>, is 100% legal. This
is not the case with
Future<void>!
Also unintuitively, the second and fourth lines fail at
runtime (it’s not “safe” to throw away the result of a
Future in this way), and the others succeed
silently.
These behaviors should make you uncomfortable. But don’t
worry! You already know the solution: use
Future<void>. I’ll break down why
this unintuitive behavior works this way.
How void works now
#
I debated a lot to myself about what the best ordering would
be for this article. There are lots of concepts to explain
here. However, I will try to go in order of most useful
information to least useful information. Therefore, with
void being much more useful than
Null in Dart 2, it makes sense to start with
Dart 2 void semantics.
The fundamental idea behind void in Dart is
derived from the goal that a void
value should not be used.
void f() {}
print(f()); // error!
This is hopefully what you expect to happen! So with that, so far so good.
It’s worth noting that achieving this goal
and opening up void as a fairly normal
type can get a bit weird:
f(void argument) {
print(argument); // Error!
// we can receive the argument, but we may not use it!
}
And this is where, in Dart 2, our new “generalized void” starts to get cool, and powerful…albeit sometimes confusing.
I pose the question to you…what type of value could we pass
in as the argument of f?
f(void argument) { … }
f(x); // what is x?
The answer to this is…anything! Because we don’t let you use
the value of x inside the function
f, we can confidently say that there
is no value that x can take
that could cause any runtime errors.
f(1); // will have no difference compared to
f(“foo”); // will have no difference compared to
f([1, 2, 3]); // will have no difference compared to
// ...
If we derive the best semantics for void based
on the idea that it is a value that will not be
used, this is what we get. Which means, it’s a
value that can be filled with absolutely anything.
It’s like a vacuum: an input with no output.
Now, there are cases where something is always error-free at
runtime, but still should cause static errors. This is a
great case for a lint! And indeed this is the idea behind
the
void_checks lint. It looks for places where you
pass anything but null into a
void location, and I would encourage teams to
enable it. It’s not required for soundness, but passing
anything other than null into a
void location is still likely to be an
accident, which the lint will flag for you.
Since all of this is grounded in foundational type theory,
void plays well with every part of Dart, even
in continuations leveraging type inference in the context of
a Future<void>:
Future<void>().then((x) {
print(x); // error! x has type “void” so you can’t print it!
// This is what we want!
});
// and yes, this is an error too:
Foo f = await voidFuture();
At this point, the average developer probably knows
everything they need to know about void
in order to effectively use it.
If you are still following along, however, there are a few more interesting nuggets.
Even with void_checks enabled,
void locations will not be guaranteed to
contain null. Take this override:
class A {
void f(Object o) {}
}
class B extends A {
@override
Object f(Object o) => o;
}
We don’t want to make this override illegal, because it’s
safe, useful, and would be a breaking change from Dart 1.
And so we are forced to accept that
void locations may hold absolutely any value.
We also cannot “optimize out” the value that
A.f() returns, because at runtime it may be a
B!
Rather, we have the clever option of making
void a sister type of Object.
After all, it may hold any value, and all values are
Objects. This is not something we designed,
it’s just reality. Recognizing that reality makes it
something we can then leverage.
By making void a sibling of
Object, we leave ourselves no
requirements that a void value go
completely unused. We try our best to keep
void
to itself, but we deliberately relax these restrictions in a
few places for the sake of backwards compatibility:
dynamic f() => voidFn(); // this is legal
voidFn() as dynamic; // this is legal
These are special cases that would be legal for an
Object, and we made it legal for
void, too, in the interest of making Dart 2 a
smoother rollout.
Making void a sibling of
Object also means that void can be
used as a type parameter without bloating the compiled
output (no “template specialization,” for you C++ folk).
This reduction is good for keeping web & Flutter apps
small, at the expense of allowing the following:
<void>[1, 2, 3].toString(); // this is legal and prints [1, 2, 3]
And lastly, it may seem useless to ever type a parameter as
void, especially since it’s a form of
Object. However, void values can
be passed to void locations:
f(void x) { … }
f(voidFn()); // this is legal
This is useful for sequencing, which is for
instance used by
Mockito
when mocking methods that return void. (Replace
f with when in the above code for
a closer approximation.)
In summary:
-
The
voidtype is a sibling ofObject. -
Almost always, a
voidobject can’t be used. -
Something marked
voidcould be, in practice, absolutely anything. -
Anything can be “thrown away” into a location marked
void, and the lintvoid_checksrestricts this behavior. -
A
voidvalue can be passed into othervoidlocations.
The bottom type
#
Before I get to talking about Null, it’s
critical we talk about the “bottom” type.
This is a naturally occurring type in type theory, which has a short academic definition with a few practical applications.
If you stop reading this section because
it’s so dang weird, that is strong evidence of my
original TLDR. Unless you want your code to be similarly
dang weird, you probably want void. Now let’s
explore this weird rabbit hole and see how far it goes, eh?
The bottom type is the subtype of all types. Put in simpler
object-oriented terms, that means it’s a
Person. And a Car. And an
Animal. And every other type from every program
ever written.
If this sounds absurd, that’s because it is. I like to think of it as a “placeholder” type. But “absurd” or “imaginary” type would also be a somewhat reasonable name for it. Instead, though, it’s called the bottom type, because it’s the bottom of the type hierarchy, which, in computer science, is upside down. ¯_(ツ)_/¯. Formally, it may be referred to by the symbol ⊥, which you may also recognize as the symbol for “false.”
If you try to imagine a value that is a Person
and a Car and an
Animal, you won’t be able to think of anything.
And surprisingly, that is where the practical usage of ⊥
comes from!
What if I write a function that never returns? There are two easy ways to do so:
loopForever() {
while(true); // first way
}
alwaysThrow() {
throw Exception(); // second way
}
What is the best return type for these two functions?
It depends. Because the function never returns, the return type doesn’t really matter. You can use any type — even the absurd bottom type, which can be used in various ways in various languages.
C++ has this as noreturn. Rust has
!, and Scala has Nothing. But my
favorite example comes from Haskell, which has a very
commonly used undefined function that aborts
the program. It returns, you guessed it, the bottom type.
If we assume an undefined function in Dart like
the one in Haskell, which merely throws an exception when
called and returns ⊥, then it would look like the following:
Foo foo = cond ? val : undefined();
In the example usage line, the program can safely run and
store val when cond is true. And
when cond is false, the program will throw an
exception during undefined(). It is safe to
“store” the result of undefined() in foo,
regardless of the type of foo, because that
store will never actually happen!
undefined() ****here does not return anything.
But the lesson here is not that we can make
foo empty…it’s that the bottom type is empty
like an empty promise. It’s emptier than empty. It just
never happens.
One thing I must be careful to state is that you can and,
depending on the usage, often should return
void from these functions in practice. Usually
code like return loopForever()
is more likely to be a mistake than a useful pattern.
However, the choice is yours to make.
The bottom type is also nice for read-only empty lists. In
Dart, Lists are covariant, so a
List<int> is allowed to be used
as a List<Object>. If you then
attempt to stick a String inside that
List<Object>, a runtime check
will catch that for you and throw an error.
That means that if we make a List of ⊥, there’s
nothing we can put into it, but we can treat it as a list of
anything:
List<int> intList = <⊥>[];
for(int i in intList) {
print(i * 2); // valid, because this simply never happens
}
There are even more interesting cases when you look at what’s called “contravariant” placement of the bottom type.
Let’s say we define a function that has a ⊥ parameter:
void f(⊥ x) {}
This is almost the opposite of the
undefined() example. Rather than a function
that never returns, we have declared a function that can’t
be called! No actual value is assignable to that parameter
x. You can’t pass in a
Person because it’s not also a
Car, and you can’t pass in a
Car because it’s not also a
Person. The only thing you could pass in is the
absurd type itself:
f(undefined());
But as we just covered before,
undefined() never returns, so
f() is still never actually called in this
case!
Typing a parameter as ⊥ may seem useless, but it has esoteric value because all functions that can be called are subtypes of functions that can’t be. **(Think about it: a function that can be called doesn’t have to be. And if a function is not called, it cannot produce runtime errors.)
If you’re still with me, take a deep breath and pat yourself on the back.
Specifically, it’s safe to cast any
Function(X) to a Function(⊥), for
any X. This is better than using Dart’s
all-encompassing Function type, because it’s
more specific.
With this you can, for instance, store any unary function in a field and call it dynamically, to get past static errors and replace them with runtime errors if you made a mistake:
Function(⊥) f;
f = (int x) => x + 1;
// the validity of 123 as an argument to f is checked at runtime
(f as dynamic)(123);
This is a neat trick to help you in a pinch.
Now we can talk about Null.
Null in Dart 2
#
The conceptual “bottom” type (a type which is a subtype of
all types) exists in Dart, but that’s not all. Such a value
also exists in Dart 2! In Dart we call it
Null, and that value is — you guessed it —
null.
Since I can make anything
null(/Null), the absurd type is
not so absurd in Dart. Which makes it a bit complicated.
Note: The value null, of course, is not a
Car, and it’s not a Person. And we
do get requests for non-nullable types in Dart. So if we do
make such a change to Dart, we’ll need a new bottom type,
likely named something to the effect of
Nothing, and it will be a truer bottom type at
that.
Not only does Null have all the same absurd
usages as the bottom type, but it also has an escape hatch.
If you ever really need to return from a function that can’t
return, or call a function that can’t be called, we give you
an out! You can pass null in there! It would
be, honestly, a bit unfair of us to do otherwise, when you
look at the whole picture.
It does give a whole lot of caveats, though, to an otherwise simple declaration:
Null nothing() => null;
Null is the most specific type for
foo(), so it’s a logical choice and a good
starting place. The analyzer is also kind enough to warn you
if you call methods on it that don’t exist:
nothing().x; // error! No member x on Null
But this may give a false illusion of safety.
nothing().toString(); // no error: Null defines toString()
Foo foo = nothing(); // no error: foo will become null
// Acceptable for all parameter types in f(x),
// runtime errors if f(x) expects non-null
f(nothing());
If your goal is for nothing() to be a synonym
for null, then this would be the behavior you
want, and Null is the correct type for your
function that returns null.
There were good reasons for suggesting
Future<Null>, and it worked while
we were coming up with the new void semantics.
However, it has the same holes:
(await futureNull()).toString(); // no error!
Foo foo = await futureNull(); // no error!
Essentially, by making some function f() return
a Future<Null>, you are making
await f() a synonym of null in
your program! That’s dangerous because you can use
null as if it were absolutely anything,
absolutely any type. I don’t think that’s what most people
want.
Why else might you use
Future<Null> instead of
Future<void>? It all goes back to
that mind-boggling bottom type. You might consider
Future<Null> for futures that
must be awaitable but that never complete, or that always
complete with an error. This is directly comparable to why a
function would return Null.
The same logic applies to
Stream<Null>: Use this for a
Stream
that never sends any events. This is directly comparable to
why a read-only empty list may be typed as
List<Null>.
Some trickier types to provide a guideline for are
StreamController<Null> and
Sink<Null>. These are comparable
to functions. The most sound reason to use these types is to
say that they should never be used. The next most sound is
that you would like them to accept only synonyms of
Null, which is reasonable only if you don’t
have void_checks turned on.
Imagine that Dart did not have null as an
escape hatch for all values. Would you want to make a type
Sink<⊥> that can only accept
events from a Stream<⊥>, which
itself never broadcasts an event? If the answer is yes, then
go for it and make a Sink<Null>.
All of these usages have weak guarantees because the value
null itself is an escape hatch for why you
otherwise would be most likely to use the
Null type itself. However, do not make the
mistake of thinking that the value is the escape
hatch itself, rather than the bunker. One is safe and sturdy
and the other is to save you the effort of digging upwards
through reinforced concrete when the bunker catches on fire
with you in it.
In summary, here are the main uses of typing something as
Null:
-
As a synonym for
null, usable everywhere as everything -
As a placeholder for execution your program will never reach
-
As a container that must stay empty so that it can masquerade as any type
As an output with no inputs
Final comparison
#If you reached this point of the article, you deserve some sort of recompense — reach out to me and I’ll see about buying you a beer next time you’re free in Portland OR. :)
I talked a lot about the bottom type, but I didn’t get into
its opposite at all, Top. The top type is, as you would
expect, the polar opposite of the bottom type. And while
Dart’s name for the bottom type is
Null, Dart’s name for the top type is one of
dynamic, Object, and
void (three heads of the same coin).
It may seem strange that words like “nothing” and “empty” in
English can mean two such dissimilar things in type theory.
Both are suitable descriptions of the exact opposite top and
bottom types, and therefore, of
Null and void. It may seem strange
that two opposite types are appealing candidates for the
same job! It may seem frustrating that Dart has so many
weird quirks and edge cases, now that you’re reading up on
which to use.
Our mistake, I think, was in ever recommending
Null to be used like void in the
first place. It would at the time have been a disservice to
users to withhold what was useful advice, given Dart 1
semantics. But we accidentally made a very esoteric
type very commonly used.
Well you’ve read the article now. What do you think? Do you
expect to come across a valid use for Null
any time soon? Do you have a firm understanding of where
it’s useful?
And more importantly, can you help us spread the good news
that void is here to make it all simpler?
More from Dart
History of JS interop in Dart
Support for Wasm just landed in the current Flutter beta, thanks to an exciting JavaScript interop milestone reached in Dart 3.3. To…
Dart DevTools: Analyzing application performance with the CPU Profiler
Whether you’re a backend developer writing a command line tool using Dart, or a UX engineer building applications with Flutter, program performance is critical to the success of your project. Command line tools should minimize latency and apps should be responsive and snappy with no dropped frames. As developers, we try our best to write performant code but sometimes it’s not clear why our code isn’t performing as well as we would expect.