Avoiding late variables in Dart

Alexey Inkin
Flutter Senior
Published in
7 min readJun 26, 2022

What is a late field

Instance fields can be initialized in 4 ways:

  1. a: at the point of declaration (line 2).
  2. b: as a constructor argument using this (line 7).
  3. c: in a constructor’s initializer list before the code in braces starts (line 8).
  4. d: anywhere else (line 10).

The last one does not work for non-nullable variables. By the time the constructor body (the code in braces) starts, the object must be fully initialized so all its fields are usable, including d in the example. So if d is not initialized in 1, 2, or 3, it fails to compile.

We can fix this by declaring d to be late:

It literally means “I will initialize it later”. And you got no error.

Read more on how this works:

Disadvantages of late variables

No compile-time check for initialization

If you forget to initialize a late variable, you only know this at runtime when the program fails.

Essentially you say “Trust me to initialize d somehow before it is read for the first time”. So the compiler skips the initialization check and inserts a runtime check. If we remove the line 10, we get no warning. If we try to read d before it is set for the first time, we get a runtime error.

This defeats many points of null safety, because the compile time guarantee of initialization is half of its profit (the other half being the inability to write null). A late variable is almost the same as declaring a variable to be nullable and then reading it with !

So introducing a late variable should be seen as the last resort before making it nullable, and not as a handy feature to move initialization from a weird initializer list into the constructor’s braces.

Runtime checks are skipped at higher optimization levels

The checks on late variables are skipped when building with -O4 flag. This makes such errors harder to debug.

Takes more memory

Your program needs to know if a late variable has been initialized or not. For this, the compiler often creates a hidden internal variable that takes memory.

Additional read and ‘if’ before each read

Every time you read a late variable the program must ensure it is initialized, so an extra check is added.

Additional write before each write

Every time you write to a late variable the program sets the internal flag to note that it is initialized.

Motivation for late, and how to avoid it

Next, we will dive into motivation for late variables and see how they could be avoided in each case. Finally, I will show when it pays to use late variables.

1. A function of other passed values

One motivation for late could be a field to store some function of other field values that are passed to the constructor:

We definitely need _calculateLength method to call it in add(). To avoid code duplication, it is tempting to also call the same _calculateLength() in the initializer list. But we cannot call it there because it is an instance method and can only be called on a fully initialized object. So we might make length late as in the example above, which is bad.

How to get it right?

1.1. Double initialization

Instead, the easiest fix is to initialize it at the definition anyway:

This only works with scalars and simplest objects with const constructors. More complex objects may not have a state considered empty, or constructing such a state just to be overwritten is expensive.

This adds one extra write, although a compiler may optimize it away.

1.2. Static method

Another solution is to make this method static. It is alright to call static methods in the initializer list because we do not use the not-yet-constructed object:

This should work even faster as there is only one write related to length.

2. Create an object, store it and a function of it

Here is another motivation for late. In this example we create an object, store it in a field, and also store some function of it in another field:

Here we must use late because otherwise the initializer of value cannot refer to bar.

If we use late, the whole initialization of value is deferred until the first time it is used. If it ever happens, the object is initialized by then so we can use any fields.

2.1. Getter

The easiest way is to not have the second variable at all and use a getter instead. It is possible if its value never diverts from its initializer, and the function to get it is as cheap as a field read. You even save memory by dropping the variable:

2.2. Factory method

If the second variable is not final and can be later updated from another source, use the factory method instead of the default constructor:

A factory can do anything. It may create the object and then call a constructor passing both the object and a derivative of it as two arguments.

Here we use a private constructor _ so the outer code can only create Foo objects with the factory method. And the usage is no different than it is with the default constructor.

If you are new to this, read about constructors, including factory constructors.

2.3. Redirecting constructor

You can also use a redirecting constructor, which is a bit less intuitive to read than the factory method:

A possible language change

We would not need this whole section if we could just write:

So far this gives an error:

The instance member ‘b’ can’t be accessed in an initializer.

… and this is the design of Dart language now. There is a suggestion to allow the initializer list to use fields that were initialized earlier, so that the above code would just work. If you wish to see it happen, you may upvote that suggestion as this is how the Dart team prioritizes their work.

3. initState

In Flutter, you may want to initialize something in the state from the widget properties. initState() is the earliest point where widget can be accessed by the state. It means that anything initialized in initState() must be either nullable or late:

3.1. Arguments in the State constructor

The easy fix is to add a state constructor with arguments:

There is a lint against that: no_logic_in_create_state

It was introduced because people were confused with the concept of a stateful widget itself, like in this question. That was before the introduction of null-safety, so I believe nowadays state constructors with arguments may have merit.

4. Awaiting asynchronous calls

Sometimes you cannot have a value for a final field immediately, e.g. you must wait for a network call. You may make the field late and set it after the call completes:

This introduces a lot of new concerns:

  • As the constructor must complete synchronously, we need a separate function for asynchronous initialization.
  • Either we must make that function public and rely on clients to call and wait for it (as in the example), or we initiate it from the constructor and introduce a flag or a future for the client to know if it is complete and the object is safe to use.

4.1. Asynchronous static method

The fix is to create an asynchronous static method that does all the waiting and then initializes the object with complete data:

5. Late local variables

Local variables can also be late if their initialization is based on a condition:

Here we also do some work under if, and it prevents using a simple ternary form like
final n = condition ? 1 : 2;

5.1. This actually works without late

Just remove late from this example. Dart is smart enough to handle this. Read more about it here.

6. Refer to the result in an expression

This code creates an overlay entry and puts the child widget in it. The overlay is dismissed when tapped:

This is called an expression referring to its own result. For this to work, the entry variable must be late. Otherwise, the line 8 triggers an error.

6.1. Create an intermediary

An expression referring to its own result is hard to read. The example above can be refactored to untangle that. Start by extracting the overlay entry so that it is unaware of how it closes itself, which already feels cleaner:

Then create an intermediary. A ChangeNotifier is the simplest option:

This intermediary is created in advance and then passed to the expression, so it does no longer refer to its own result.

Then you can remove the overlay entry when the intermediary is triggered.

Unfortunately, ChangeNotifier.notifyListeners() is @protected, so this usage triggers a warning. A common fix to that is to make a trivial subclass:

Cases with no good alternative to late

All legitimate uses of late that I have seen boil down to circular references.

In the example below, we must save StreamSubscription to cancel it later. The subscription is only created by listen() method which needs a callback, and we need this callback to reference this:

An alternative would be a nullable variable because the field is not used much anyway:

This way we lose final, and a new reader must scan the code to see if the subscription ever changes. So late final seems the lesser evil.

Another common example is TabController, which needs TickerProvider:

The code will expect the controller to be not null, so it’s better to have it non-nullable even if late.

Anyway, do your best to initialize a late variable at its declaration, as _tabController above. This does not create a gap when the variable is not initialized.

Give some feedback

Do you find yourself using late fields for reasons not covered here? Do you have another workaround for any of the cases? Let me know in the comments, so I can add it here.

--

--

Alexey Inkin
Flutter Senior

Google Developer Expert in Flutter. PHP, SQL, TS, Java, C++, professionally since 2003. Open for consulting & dev with my team. Telegram channel: @ainkin_com