The new lint in Dart 3.2

Alexey Inkin
Flutter Senior
Published in
5 min readNov 17, 2023

Dart 3.2 is out yesterday. The official announcement goes into many new things there. What it does not tell us is a new lint they added.

annotate_redeclares

Let’s set the stage first. A new feature is coming in some of the next Dart releases, and it is called ‘extension types’. It was easier to understand when the working title for it was ‘views’, but they did not want to introduce a new keyword, so they renamed it to ‘extension type’.

Anyway, we will be able to use a ‘view’ on a class to only expose some of its interface. This is useful when:

  • You do not control the hierarchy yourself. Like if you want to make an AddOnlyMap that would wrap a Map and only expose methods to read or add. Ideally, you want Map to implement AddOnlyMap because inheritance is an intuitive way to add functionality, but you cannot control those built-in types. So you can make a view.
  • You have a local use case for some of your types where you want a subset of its interface available, but it is not significant enough to introduce that interface into the type hierarchy visible to your clients.

The syntax is the following:

extension type AddOnlyMap<K, V>(Map<K, V> map) {
void addAll(Map<K, V> other) => map.addAll(other);
// All other reading and adding methods.
}

It has nothing to do with just extension, you can forget it completely while diving into this new subject.

In the first line, you specify what this ‘view’ wraps. In this case, we are wrapping a Map<K, V> that we call map and can refer to it by that name when we dispatch the calls.

An AddOnlyMap is then constructed like below. Note that it only exposes the methods we manually created, like addAll():

You can see that the wrapped object is still accessible, so we can still delete entries like this:

aom.map.clear();

We can make it full-proof by making the wrapped object private with the underscore:

extension type AddOnlyMap<K, V>(Map<K, V> _map) {
// ...

But then someone can still cast your wrapper to the original type:

(aom as Map).clear();

All of that could be done earlier by wrapping the object in a new class:

class AddOnlyMap<K, V> {
final Map<K, V> _map;
const AddOnlyMap(this._map);

void addAll(Map<K, V> other) => map.addAll(other);
// ...
}

But that results in overhead because this new type exists at runtime, and all methods get forwarded at runtime. In contrast, an extension type only exists at compile time and adds no overhead against just calling the methods on the original object. This is why casting to the original type works. Read more on that in the official discussion.

This feature is available in Dart 3.2 as an experiment. To use it, add
--enable-experiment=inline-class to the Dart command line when you run or build the program.

Now that we set the stage, let’s switch to cats to better explain the new lint because maps were good to explain the idea but not the problem.

Let’s say you have this hierarchy:

class Animal {
void sound() {
// Mute by default.
}
}

class Cat extends Animal {
@override
void sound() {
print('Meow.');
}

void play() {
print('Chomp!');
}
}

Now you want a SoundOnlyCat. It will make sure your clients will not play with it and not get bitten. Casting it to Animal would solve this, but you want your clients to always be passed a Cat and not a Wolf for their safety, so you must use a view on a Cat:

extension type SoundOnlyCat(Cat _c) {
void sound() => _c.sound();
}

Next, you get lazy on manually forwarding the methods and just forward everything that was in Animal. You can do it like this:

extension type SoundOnlyCat(Cat _c) implements Animal {}

It is probably not a good idea in a general case because you may later add something to Animal that you do not want in SoundOnlyCat and forget it, but there are still use cases for that mass forwarding.

Next, you want your sound-only-cat to sound differently:

extension type SoundOnlyCat(Cat _c) implements Animal {
void sound() {
print('I want to play.');
}
}

And this is where it gets problematic. Animal has sound(), but this new method does not override it but shadows it:

final cat = SoundOnlyCat(Cat());
cat.sound(); // I want to play.
(cat as Cat).sound(); // Meow.

It can even have a different signature:

extension type SoundOnlyCat(Cat _c) implements Animal {
void sound({required bool loud}) {
print('I want to play' + (loud ? '!!!' : '.'));
}
}

final cat = SoundOnlyCat(Cat());
cat.sound(loud: true); // I want to play!!!
(cat as Cat).sound(); // Meow.

So this easily gets messy. If you then rename the method in the extension type, it will no longer shadow the one in Animal, and you can accidentally call a method you wanted to not be visible. If they have the same signature, you will not know it until clients report bugs to you.

When you shadow anything, you want to make sure you do this on purpose. For this, a new annotation @redeclare is used. It is defined in meta package starting at version 1.10.0.

Here comes the new lint annotate_redeclares. It forces you to annotate whenever you redeclare anything on an extension type:

import 'package:meta/meta.dart';

extension type SoundOnlyCat(Cat _c) implements Animal {
@redeclare // OK, or lint without it.
void sound() {
print('I want to play.');
}
}

Then, if a method stops being redeclared, this annotation produces a warning for you.

The Earlier Lints

If you have missed my articles on older lints, read them now:

Enabling the New Lint

Read this article on how to enable those new lints manually.

Alternatively, you can use my package total_lints which enables most of the lints for your project. I use it to avoid repeating the linter configuration for my projects.

Never miss a story, follow my Telegram channel: ainkin_com

--

--

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