Skip to content

Dart treats a final nullable property as nullable even after checking that the property is not null #1415

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
lsalazarm99 opened this issue Jan 23, 2021 · 53 comments
Labels
field-promotion Issues related to addressing the lack of field promotion request Requests to resolve a particular developer problem

Comments

@lsalazarm99
Copy link

lsalazarm99 commented Jan 23, 2021

Considering this code:

void main() {
  Person person = new Person(
    name: "Leonardo",
  );
  
  if (person.jobTitle != null) {
    showInConsole(person.jobTitle); // Error: The argument type 'String?' can't be assigned to the parameter type 'String'
  }
}

class Person {
    final String name;
    final String? jobTitle;

    const Person({required this.name, this.jobTitle});
}

void showInConsole(String text) {
  print(text);
}

In the error line, person.jobTitle will not be null as it was checked for that before, but the analyzer keeps it with its original signature. Because of that, it is not usable inside the showInConsole function.

Some workarounds?

  • Using a local variable for the property. In this case, I'm not sure if the analyzer keeps the local variable with its original signature after the null check, but it doesn't show an error anymore.
String? jobTitle= person.jobTitle;

if (jobTitle != null) {
    showInConsole(jobTitle); // no errors
}
  • Using the ! operator. But I'm not sure about this. Coming from TypeScript, this is a bad practice because if I remove the null check for some reason, it won't show the error and it will be throwing a runtime error.
if (person.jobTitle != null) {
    showInConsole(person.jobTitle!); // no errors
}

The first workaround is the safest way, I think. But what if I change the type of cardBrand in the future? I would need to change it in every location I check for the property to not be null. Also, it could be annoying to create a local variable for every property you need to check.

If the analyzer can follow the flow of the first workaround for a local variable so it knows it's not null, shouldn't it do the same for the property of a local variable?

@lsalazarm99 lsalazarm99 added the request Requests to resolve a particular developer problem label Jan 23, 2021
@lsalazarm99
Copy link
Author

@tatumizer In that situation where you are getting the value from a function, I can understand that even if you check the return value to not be null, it doesn't mean the value won't be null when you run the function again (unless it is a pure function, but that might be more complex).

But what I am doing is not running a function so a non-ready-value. I am accessing a class property that is even marked as final so if I already checked for that property to not be null, it's impossible for that property to be null.

Also, I know person could be resigned so jobTitle could be null even after check. But if I mark person as final, the error is still present.

If I am checking the final property of a final variable to not be null, shouldn't it be enough for the analyzer to know it won't be null never?

@lsalazarm99 lsalazarm99 changed the title Analyzer keeps treating a nullable property as is even after checking for that property to not be null Analyzer keeps treating a final nullable property as is even after checking for that property to not be null Jan 23, 2021
@lsalazarm99
Copy link
Author

I agree with you that, in your example, the error should be shown. As you said, jobTitle is overridden by a getter (so a function). And as a function, its return value might not be the same after every execution. That's why it should show an error.

But in my example, jobTitle is not a function. Is a final property. Yes, it can be overridden by another class, but I'm not using that other class. I'm using the class Person and its already final declared jobTitle. If I try to update the property or the final variable person, the analyzer won't let me because it will show an error telling me that it's not possible to set again those values.

void main() {
  final Person person = new Person(name: "Leonardo");
  
  person = new Person(name: "Eduardo"); // Error: The final variable 'person' can only be set once
  person.jobTitle = "Developer"; // Error: 'jobTitle' can't be used as a setter because it's final
  
  if (person.jobTitle != null) {
    // Since person can not be reassigned either its property jobTitle,
    // and I have already checked that person.jobTitle is not null,
    // it is safe to say that person.jobTitle won't be null never.

    showInConsole(person.jobTitle); // Error: The argument type 'String?' can't be assigned to the parameter type 'String'
  }
}

class Person {
    final String name;
    final String? jobTitle;

    const Person({required this.name, this.jobTitle});
}

void showInConsole(String text) {
  print(text);
}

Since those values can not be reassigned and the analyzer knows it, shouldn't it also know that if I'm checking for those values to not be null, those values won't be null inside the if statement?

In case those values could be reassigned, how could you reassign this so it is reasonable to show an error?

final Person person = new Person(name: "Leonardo");

@lsalazarm99
Copy link
Author

Well, you could argue that the compiler can see the declaration Person person = new Person(name: "Leonardo"); and thus, the Person is just a basic Person, in which the jobTitle is final.

In this situation, checking for the property to not be null should be enough since, as you said, the compiler can see the declarations.

But in the next version, you can modify it: Person person = giveMeAPerson()); And now compiler cannot assume anything about the real type of Person and hence about the finality of jobTitle.

In this situation, where the code has changed, since the compiler doesn't know because I'm using a function, it is reasonable to show the null-safety error.

Maybe there is something I'm still not getting, but I'm not talking about the second situation. I'm talking about the first situation, which is a different one because the compiler can see that the property won't be null.

@srawlins
Copy link
Member

but I'm not using that other class

A small change in your program will break your program in ways you don't expect (the reason for the breakage is subtle).

I think this is an important consideration. I think the language team has looked at the various cases in which a developer says "but I know the field isn't overridden," such as private fields (#1167), @sealed classes. Even in your case, the Person() constructor could be a factory constructor which returns a PersonToBeDemoted. Right now there are limited controls of variance in Dart; any time the static type of an object is Person, this means that the runtime type could be any subtype of Person.

One consolation is that the runtime behavior might take knowledge into consideration with optimizations. Looking at:

if (person.jobTitle != null) {
    showInConsole(person.jobTitle!);
  }

If the compiler has optimizations (based on visibility, exact type, etc), then it may not need to execute that ! null check. But for the sanity of the language, the ! must remain because of specification of the static types.

@srawlins
Copy link
Member

Duplicate of #1330

@srawlins srawlins marked this as a duplicate of #1330 Jan 23, 2021
@Cat-sushi
Copy link

label:field-promotion is also related to this issue.

@Cat-sushi
Copy link

Cat-sushi commented Jan 25, 2021

If fields are promoted in some case and aren't promoted in other case depending on global analysis, it is also confusing very much.

@lrhn
Copy link
Member

lrhn commented Jan 25, 2021

@tatumizer wrote:

Dart AFAIK always compiles the program from the sources - there's no partial compilation, precompiled libraries, etc.

That is not correct. Dart does, at least for some compilers, have modular compilation. I believe the development compiler (dartdevc) is one. Among other things, that's what enables hot reload. If you had to wait for the entire program to be compiled, you'd at most have luke-warm reload. 😁

Also, a package is a program part which is also compiled and run (for testing) independently of the program(s) it's part of.
If having a subclass changes the behavior of the superclass, then a superclass method getting field promotion due to not having a subclass, would break in a program where a subclass is added.

It is vitally important to Dart's approach to object oriented programming (and ditto for most other languages with subclasses) that you can write a superclass without knowing its subclasses ahead of time. If you can write code in an instance method, which is then invalidated by a subclass, then you can't really use that feature safely anywhere. (Basically what @Cat-sushi said, if promotion inside a method depends on a global analysis of the entire final program, then it's incredibly fragile.)

Using global information for optimization is great, it enables more possible optimizations.
Using global information to change semantics is not, it makes the semantics unpredictable and therefore unusable.

If you could declare that a class cannot have subclasses (or not subclasses outside of the declaring package), then you don't need global information (or seen differently, you have global information because the local declaration ensures global cooperation. And that only works with actual language features, not annotations that might or might not be checked).

@lrhn
Copy link
Member

lrhn commented Jan 25, 2021

It's about being able to see where things can fail. If you see person.jobTitle!, then you know that a null check occurs there. You can look to the surrounding code for hints to why the code feels safe to assume that the value won't be null.
And the author might consider whether there are better ways to do the same thing, one where you only make one type check.
That's why visibility is good, it allows you to notice things. It doesn't prevent you from doing something stupid if you really want to, but then again, nothing ever really does.

A ! is still a run-time cast. It's a cast from T? to T (where the type system can usually figure out which non-nullable type you mean). It's still able to fail at run-time, just like as T would. We're definitely not trying to encourage anybody to spread ! casts around their code. I hope that future code will more often be written in a way where it's not necessary to use !, or where it's used as sparingly as as should be now.

Existing code didn't have that possibility, and migrating existing code might (as the path of least rewrite) require adding ! in places where the current code would also fail (possibly later) if the value is actually null.

@lsalazarm99
Copy link
Author

Clearly you guys know more than me in this subject, and that's probably why I still can't understand something that seems obvious for me and probably many of the people with this issue.

final Person person = Person(name: "Leonardo");

In this sentence, I'm saying that the type of person is Person, and yes, it could be any subtype of Person. But then, through using Person() I'm saying that actually is not a subtype, but is just a Person.

So now the analyzer have all the information it needs (the exact type that the variable is and the information about that type).

Given that, shouldn't it know that if I'm checking for person.jobTitle to not be null, it won't be null?

Sure, in a future the code can change. If instead of using Person() I use Developer(), the analyser should then treat that variable as that subtype, but nothing else. If Developer overrides jobTitle with a getter, it will start complaining where you use your person variable. If it doesn't override it, it won't complain because it's still a simple nullable property that I already checked to not be null.

I can even change the jobTitle of Person and making it a getter and sure, I will understand it would need some refactor in my code because that's usually what it means to change the structure of a class.

But right know, in that line of code I wrote at the beginning (and the structure of Person), the analyzer have enough information to know that if I'm checking for jobTitle to not be null, it won't be null.

@lsalazarm99
Copy link
Author

@tatumizer Many thanks, finally I understand what's happening and why is the shown error "valid".

@lrhn
Copy link
Member

lrhn commented Jan 26, 2021

I generally create local variables if I have to check-and-use a nullable instance variable. That is a way to avoid the !, it's just a tad verbose.
I would very much like to have a feature which can check-and-bind in one operation, perhaps something like if (x is Foo foo) or if (x is! Null foo) which checks the value and binds it to a new variable only if it's not null. That allows me to introduce a new variable, check the value and promote the value along the successful check, which is the same thing I'm already doing, just with less verbosity.

I don't think we'll ever see sound promotion of virtual getters. We might be able to see it on non-virtual getters, but even there I'm somewhat against it because it would probably not work on getters, only fields, and that breaks the field/getter symmetry and locks classes into their current implementation.

Interfaces are promises about API and behavior, but only the API can really be checked by a computer. If we know that x.foo is a getter returning an int, that is all we know. We do not know that it's the same value each time you read it.
Dart has chosen to have fields and getters/setters be symmetric, so you can replace one with the other. That is, being a field is an implementation detail, not an API property. C# did something different, where they have getters and setters in the API, which are just methods called in special ways, but fields are completely different and (IIRC) not even virtual - so you have to write your getter/setter pair to access the field in a virtual way (like Java's getFoo/setFoo, just with nicer call syntax).
If we don't want to break that field/getter symmetry in Dart (and we still don't), we can't start assuming that even a final static field is promotable, because it would make it a breaking change to refactor that field into a getter.

Our problems here are largely self-inflicted. Because we can't look below the API to see that something really doesn't change (because either it's not guaranteed to be true because the getter is virtual, or because the promotion we'd do would then be fragile and depend on the getter staying a field), we can't promote anything except local variables.
I do not believe that can change without dropping some very fundamental assumptions in the Dart design.

So, I want to focus on providing a different and better way to get a promoted variable from any existing value, rather than focusing on linking separate reads of a fundamentally unknowable getter.

@rrousselGit
Copy link

Would it maybe make sense to introduce a syntax that prevents overriding a final variable with a getter?

@xvrh
Copy link

xvrh commented Jan 26, 2021

The proposal here: field promotion with runtime checking looks like the best solution. We trade a little bit of safety to A LOT MORE convenience.

All the examples showing why fields can't promote don't look realistic. In real codebase, 99% of fields/getters don't change type between 2 consecutive calls.

@rrousselGit
Copy link

I personally learn more toward sealed classes, preventing inheritance

If it is impossible to extends/implements a class, we know for sure that a final variable never changes, and the type-inference can work properly.

@lrhn
Copy link
Member

lrhn commented Jan 26, 2021

Even sealed classes wouldn't take away the getter/field symmetry problem.
You'd still have to explicitly mark the field as unchanging, in a way where that can be strictly enforced by the compilers.

Say (obvious strawman): final final int x = 42;. This field cannot be overridden or implemented by anything but another final final declaration, so it's guaranteed that reading it twice will always give the same result. (And it's considered a breaking change to make a final final field into a mere final field, you're changing not just the implementation, but also the promise that the implementation won't change). Otherwise it's just a final field.

That would work even without sealed classes (you can implement the interface, but it better be with a final final x too!). It's an explicit promise to make a field always be unchanging. (Heck, we can even introduce final get x => ... getters that, somehow, promise to only ever have one value per instance, maybe by only returning the value of other "final" declarations. Starts to feel like const again, but just for the immutability).

@rrousselGit
Copy link

Even sealed classes wouldn't take away the getter/field symmetry problem.

Why would that be a problem on a class that cannot be extended/implemented?

As it would not be possible to override a final with a getter. So there's never ever a case where a final property would change by reading it twice.

@Cat-sushi
Copy link

@tatumizer
It is the problem that the current type system doesn't have double derived type which can't be 0, and it is not directly related to null safety.

@srawlins srawlins changed the title Analyzer keeps treating a final nullable property as is even after checking for that property to not be null Dart treats a final nullable property as nullable even after checking that the property is not null Jan 26, 2021
@lrhn
Copy link
Member

lrhn commented Jan 26, 2021

@rrousselGit Getter/field symmetry works both ways: A client of a class cannot see whether a property is implemented with a user written getter or an implicitly induced field getter (it's always a getter, sometimes it comes from a field), and the author is free to change between the two. It's an abstraction layer - the interface only tells you that you can read something, it doesn't promise how it's implemented.

If you can get promotion from a final field on a sealed class, but not from a getter on a sealed class, then the author of the class is no longer free to change the field to a getter. It has started mattering to clients how the getter is implemented.

The author might not know about this, after all, he might never see the code. So he could think that changing a final field to a getter on his already sealed class is a perfectly good refactoring. Instead it's a breaking change.

If that applies to all final fields of sealed classes, without any way to opt out of it, then a sealed class becomes less modifiable than a non-sealed class, which is the opposite of the reason to seal (to allow safely adding members later, because we know that no-one is implementing the class).

That is why I'm opposed to any attempt to derive unmodifiability of a getter from its choice of implementation — that will immediately make that implementation choice a part of the public API and break the deliberately introduced abstraction layer.
I want you to have to opt in to being "unmodifiable", so that it's a deliberate choice and promise that the implementation has certain properties.

I think this is still a far more complicated approach than giving a way to promote-and-bind, which can be used for arbitrary values, rather than adding even more features to the object interface model, and then maintaining and preserving "unmodifiabilty". The bang is just not worth the bucks, they're better spent on something with more bang.

@Cat-sushi
Copy link

@xvrh
If my understanding is correct,
in current Dart, even late variable have to be checked only once in every same context.
On the other hand, #1187 and #1188 are very expensive, because the compiler inserts null check everywhere referencing the fields.
So, I prefer #1191, #1201 or #1210.

@Cat-sushi
Copy link

Cat-sushi commented Jan 26, 2021

@tatumizer
I understand that It is a design decision that x might returns different value every time referenced.
And it is not a issue of type system.

@Cat-sushi
Copy link

print(x) is valid regardless of if(x == 5).
showInConsole(x) is invalid regardless of if (x != null).
That is the difference.

@rrousselGit
Copy link

@lrhn

I want you to have to opt in to being "unmodifiable", so that it's a deliberate choice and promise that the implementation has certain properties.

But don't sealed classes qualify as an "opt in"? As the enhanced type-inference would be only available if the class is sealed, which requires an extra keyword on the class definition

In the end, the final final you're describing could be a subset of the "sealed" feature where:

sealed class Example {
  Example(this.a, this.b);

  final int a;
  final int b;
}

is equivalent to:

class Example {
  sealed Example(this.a, this.b);

  sealed final int a;
  sealed final int b;
}

As for getters, I believe late is the solution, where instead of:

int get c => a + b;

we can do:

late final c = a + b;

@Cat-sushi
Copy link

Cat-sushi commented Jan 26, 2021

Grammatically correct.

@Cat-sushi
Copy link

No.
Overriding, field/getter symmetry prevent the promotion.

@Cat-sushi
Copy link

@tatumizer
duplicate of #1188

@esDotDev
Copy link

esDotDev commented Mar 10, 2021

This really needs a solution now that nnbd is rolling into flutter at large.

Without one, we're going to end up with a bunch of landmines like this:

void _handlePastePressed() async {
    if (controller == null) return;
    int start = controller!.selection.start;
    removeTextRange(controller!.selection.start, controller!.selection.end);
    String? value = (await Clipboard.getData("text/plain"))?.text;
    if (value != null) {
      addTextAtOffset(controller!.selection.start, value);
      // Move cursor to end on paste, as one does on desktop :)
      controller!.selection = TextSelection.fromPosition(TextPosition(offset: start + value.length));
    }
  }

This is not safe code at all, if a developer accidentally removes the null check at the top, the compiler would say nothing at all, and this is just a RTE waiting to happen.

The right way to code this would be to add somethng like 9 ? characters and several inline ?? 0, or convert controller to a local var, and do the nulll check on that. But both are clumsy and verbose to write, especially if you're checking 3 or 4 different vars.

My worry is that devs will opt for the ! here, since it's by far the least invasive and easiest to read, but they are really filling their methods with potential landmines. And all this to protect some language peculiarity that most aren't aware of?

It seems sad that such a code-smelly feature like overriding final fields with accessors, is forcing such a big weakness in something as core as sound null safety.

From a pragmatic standpoint, this will happen constantly as Flutters entire architecture is to bind us to class level fields, and then reference those fields in our build methods. We already have to define them twice generally, and now we will have to copy them a 3rd time any time we want to check if they are null? It's not really an ok solution tbh...an as a result no one outside the language team will bother to do it., ! operator will reign supreme, and really undo much of the wins here.

@esDotDev
Copy link

esDotDev commented Mar 10, 2021

The only way I can see this happening Flutter is this bizarre concoction:

class Foo extends StatelessWidget {
  const Foo({Key? key, this.value}) : super(key: key);
  final String? value;

  @override
  Widget build(BuildContext context) {
    return (value == null) ? Container() : Text(value);
  }
}

class Bar extends Foo {
  int _callCount = 0;
  @override
  String? get value => (_callCount++ < 1)? "" : null;
}

Intuitively, I'm sure most developers would be shocked to learn you can override a final field, with an accessor without a peep from the compiler, and this is a source of most of the confusion. No one knows you can do this, and no one does this, cause it's bizarre and probably an anti-pattern out of the gate. Devs expect a field to remain a field in general, which is why this is so hard to grok.

In C# the compiler would warn you to make this accessor as 'new' because it's replacing the base value and this should be called out to anyone reading (abnormal, and bordering on anti-pattern as well).
image

And if you tried to mark it as override, it would not allow it at all:
image

This is along the lines of what most developers would expect I think.

@lrhn
Copy link
Member

lrhn commented Mar 10, 2021

Overriding a field with getters/setters makes sense for intercepting more than it does for replacing.

Overriding a field (final or not) with a getter, and totally shadowing the field, is an anti-pattern because it's a waste of memory.
A much more reasonable pattern would be a getter which forwards to the super-class field, but does something else on the side (perhaps logging access), or even more reasonably, having a setter which forwards the value to the field, but also reports the change like an observable.

Fields in Dart are always virtual, unlike in C# and Java.
That's why we don't need an extra getFoo/setFoo virtual method pair like in Java.
C# has a syntax shorthand for it, public int Foo {get; set;} which creates a field with a fresh name, and default virtual get/set operations backed by it. It's like Dart only has that syntax, there are no non-virtual fields.

@esDotDev
Copy link

Thanks for the explainer, that makes sense. So even when this ability is used, it would be very poor form to return something other than is expected by the base class.

So we are effectively optimizing not only for an edge case, but a very bad one. It would be nice if we optimized the experience of null-check for the main use case, and make the people writing these intercepting routines on sub-classes, jump through the hoops.

It's commonly understood that, as the implementer of the sub-class, it's on your to understand the inner working of your super class, and not break it.

@jamesderlin
Copy link

Here's my bet.
Suppose the compiler automatically transforms every statement like this:

if (x != null) {
   use(x);
}

into

if (x != null) {
   use(x!); // ! is automatically inserted by a compiler.
}

Given that almost everybody is going to manually make that transformation anyway, I don't see the harm in doing that automatically.

@esDotDev
Copy link

esDotDev commented Mar 11, 2021

This seems like it would work ok? The issue is not the ! operators themselves, but rather that they can be
"orphaned" if the preceding null check is ever removed, so by having the compiler do this automatically we sidestep the issue?

@eernstg
Copy link
Member

eernstg commented Mar 11, 2021

About this idea:

Here's my bet.
Suppose the compiler automatically transforms every statement like this:

if (x != null) {
   use(x);
}

into

if (x != null) {
   use(x!); // ! is automatically inserted by a compiler.
}

That's #1188.

@esDotDev
Copy link

esDotDev commented Mar 11, 2021

No, the issue is exactly ! operators themselves :-)
Compiler cannot silently insert an exclamation mark simply because it's logically wrong. It's very easy to provide a counterexample - the bug report against this feature will be filed on day one. Who knows how this bug can be exploited, but good people will find a way :-)

Lol, fair point! But I am speaking purely from a pragmatic standpoint, I do not mind placing ! operators, or having them exist after an explicit null-check, none of that makes my app code more brittle.

What I do mind, is putting in bang operators, that are semantically weakly linked to some other line in a method, that must never bee removed. We need some way to say, if this null check was removed, the compiler should light up again.

Totally agree, this really should not be necessary at all, but also don't want to wait 3 yrs for a perfect solution, when a good enough will do.

@cedvdb
Copy link

cedvdb commented Mar 31, 2021

We need some way to say, if this null check was removed, the compiler should light up again.

Isn't that exactly what it would do ? If the null check is removed, the ! isn't inserted and the compiler will light up.. This discussion is very bizar. It's counter intuitive to use ! after a null check. If a getter overrides that null check as someone proposed, you cannot even know, because you are not supposed to go into the source code after a null check.. How do you even work with such variables ? If you take that at face value it makes the null check an anti pattern (which it obviously isn't), because it can't be trusted . At that point, the code the null check is run upon is just not robust enough and there is nothing the language can do against it, so far.

I'm talking about this:

String? get jobTitle=> requestCount++==0? super.jobTitle: null;

This kind of code should be documented and not let the remaining 99.9999% of code suffer from non intuitiveness..

@esDotDev
Copy link

esDotDev commented Mar 31, 2021

How do you even work with such variables

You must read them once and cache the result locally.

Most seem to agree that this is a flaw in the language design that this hole exists, but also that the ship has sailed and it is what it is.

So there's various proposals to help mitigate this, I think the leading one is probably if-vars, which would be syntactic sugar for local var shadowing.

if(var jobTitle == null) return;
print(jobTitle.length); // jobTitle is now a non-null string
jobTitle = "NewTitle"; // this gets copied back to class field

#1201

@cedvdb
Copy link

cedvdb commented Mar 31, 2021

How do you even work with such variables

You must read them once and cache the result locally.

I thought about that but is that really a language you'd have fun using?

Most seem to agree that this is a flaw in the language design that this hole exists

This hole exists in many languages and again the hole is more in the underlying implementation of the getter than the language itself.. I can get behind the fact that it could lead to hard to debug bugs but well so be it tbh

You can make the same argument for a setter..

So there's various proposals to help mitigate this, I think the leading one is probably if-vars, which would be syntactic sugar for local var shadowing.

This makes sens but wouldn't virtually all if null check be if var then? That is if you really wish to be null safe. IMO this is just a non issue in most cases and this proposal would make if var the go to just to be safe on the offchance there is something weird happening in an adjacent library.

@esDotDev
Copy link

esDotDev commented Mar 31, 2021

I thought about that but is that really a language you'd have fun using?

You're preaching to the choir here, I totally agree. No one writes code this pedantically by hand, I'm not sure why the compiler needs to be so strict here, especially when that strictness leads directly to orphaned ! operator's shooting your null safety in the foot.

@natebosch natebosch added the field-promotion Issues related to addressing the lack of field promotion label Feb 15, 2022
@danielgomezrico
Copy link

This issue makes the code noisy, it makes:

  • Adding all the ! where it makes no sense adding noise
  • Devs gets used to add ! everywhere and that may cause issues where it should not exist
  • Devs that are new to nullable types get confused about the concept, and to me that is pretty important.

I remember that this was an issue on Kotlin, and they fixed it, is there any possibility to add some priority to this?

@mateusfccp
Copy link
Contributor

@danielgomezrico

I suppose that you've read the discussion above that provides the information regarding why this is this way and why it's not simple to solve it, at least not in a non-breaking way.

There are many proposals that intends to propose this issue completely or partially, you may have a look at them by the label field-promotion.

Although I agree that this issue is a little annoying, your three problems may be simply solved by instructing your team. For example, in my team we instruct our developers to never use !, and use promotion instead. PRs that use ! instead of promoting the values are not accepted.

kaysavps added a commit to kaysavps/piwigo-flutter-app that referenced this issue Oct 3, 2024
kaysavps added a commit to kaysavps/piwigo-flutter-app that referenced this issue Oct 3, 2024
…ty is already checked as not null. Issue similar to dart-lang/language#1415"

This reverts commit b63b398.
@caseycrogers
Copy link

caseycrogers commented Nov 19, 2024

Has this discussion changed at all with the introduction of final? From what I can tell, the key problem now that final exists is as follows (copied form @lrhn earlier):

Getter/field symmetry works both ways: A client of a class cannot see whether a property is implemented with a user written getter or an implicitly induced field getter (it's always a getter, sometimes it comes from a field), and the author is free to change between the two. It's an abstraction layer - the interface only tells you that you can read something, it doesn't promise how it's implemented.

Yes, I agree, it'd be pretty non-ideal if using the final class keyword implicitly made field->getter migrations breaking changes. But, having to constantly create local variables just to get null inference is also a huge pain.

Flutter's AsyncSnapshot is a good example of this. I have to check data for nullability twice and/or unpack it into a local variable. I've seen a lot of codebases that have their own bespoke equivalent to AsyncSnapshot too that adds bells and whistles and every time the tension between redundant and runtime-brittle ! checks vs verbose unpacking is a pretty deeply felt pain.
See the Flutter FutureBuilder example which chooses the brittle path (display the data object, assuming at runtime that it is not null):
https://api.flutter.dev/flutter/widgets/FutureBuilder-class.html
The "check null, then use !" pattern is so common that AsyncSnapshot even provides a convenience wrapper for it with requireData. FWIW, the ship has probably sailed on AsyncSnapshot as even if we could get null inference on attributes of final classes, making AsyncSnapshot final would probably be far too much of a breaking change. But given that most projects (including the one I'm working on right now) use bespoke alternatives anyway, those could at least migrate to final.

So we have to weigh the tradeoff here. Personally, I think improved convenience and/or safety for null checking attributes is way more important than the API purity and marginal reduction in potential breaking changes, but I understand if the Dart team still comes down on the other side.

@rrousselGit
Copy link

Quite a lot has changed in two years.

In 2023, we got pattern matching, which empowers if to define variables:

// Equivalent to `final field = obj.field; if (field != null)`
if (obj.field case final field?) {
  field.doSomething(); // OK
}

And promotion for private fields:

if (obj._value != null) {
  obj._value.toDouble(); // OK
}

Ideally promotion would work for public fields too. But I doubt that this is realistic, given how Dart works.

To me, this issue is solved.

@caseycrogers
Copy link

caseycrogers commented Nov 19, 2024

I don't think the pattern matching really helps at all because you're almost always using an early return pattern where you return if null. The alternative-checking if not null and putting the body in the if brackets-tends to be untenable because you end up with unreadable amounts of nesting.

Private fields is nice, but also doesn't help in most scenarios.

The current place where this most annoys me is with hooks-the code base I'm working in right now is filled with hooks that construct and return a record that includes all nullables like errors, data and "refresh/set" callbacks. Records were used instead of classes because the team considered null type promotion a non negotiable requirement. But now the type signatures and general contract for the hooks are really obtuse.

My two cents is that the experience has only improved for a very limited slice of scenarios.

@rrousselGit
Copy link

Early returns are a fair shout out.

I wonder if we could have a variant to if case but with early returns in mind.

For example:

// !case is the reverse of case
if (obj.field !case final field?) return;

field.doSomething();

@mateusfccp
Copy link
Contributor

I don't think the pattern matching really helps at all because you're almost always using an early return pattern where you return if null. The alternative-checking if not null and putting the body in the if brackets-tends to be untenable because you end up with unreadable amounts of nesting.

I often do this, and it's not less readable (I actually think it's more readable than early returns, to have all returns in the same nest level). Most of the time (like 90% at least in my personal experience), you will have only a single level of nesting, not a nesting hell.

This is a subjective experience, though.

@ghost
Copy link

ghost commented Nov 19, 2024

if (obj.field !case final field?) return;

field.doSomething();

This is logically impossible to implement if you allow variables introduced in case clause to shadow those from the outer context.

@caseycrogers
Copy link

I often do this, and it's not less readable (I actually think it's more readable than early returns, to have all returns in the same nest level). Most of the time (like 90% at least in my personal experience), you will have only a single level of nesting, not a nesting hell.

This is a subjective experience, though.

I find, in the wild, you often get past one level of nesting and the early return pattern almost immediately gets less nested and easier to parse. FWIW, the current team I'm on feels so strongly they even have a custom linter to enforce the early return pattern.

@rrousselGit
Copy link

rrousselGit commented Nov 19, 2024

if (obj.field !case final field?) return;

field.doSomething();

This is logically impossible to implement if you allow variables introduced in case clause to shadow those from the outer context.

I'm not sure about that, but I'm not attached to this exact syntax.

This feels reminiscent of the guard discussions.
We could imagine something else, like:

void fn(MyObject obj) {
  guard final field? = obj.field else {
    return;
  }

  field.doSomething();
}

Or whatever the syntax needs to be.
That else return; could even be optional in void functions.

@ghost
Copy link

ghost commented Nov 19, 2024

Quote:
In guard or if let statements, you cannot shadow a constant or variable declared in the outer scope using the same name.

let x = 10
guard let x = someOptional else {  // Error: Cannot assign to value of 'x' because it is already declared.
    return
}

End Quote
(source: chatGPT)

In dart, there's no such restriction

@caseycrogers
Copy link

caseycrogers commented Feb 24, 2025

Can we get null promotion for nullable fields within a final class? Sort of like we already have with private fields? I think this unlocks 90% of the value (in the Flutter context) without hitting any of the blockers that have come up in this thread so far.
Credit to @lukepighetti for the idea.

Namely:

  • We already don't abstract everything to a getter from within the class (see private attributes getting null promoted)
  • If the final null promotion only applies within-class, then field -> getter migrations still aren't breaking changes because the only places where you got the stronger null promotion were within the class itself

It would look like this:

// Final isn't a problem because you're rarely ever subclassing widgets anyway.
final class FooView extends StatelessWidget {
  const FooView({this.foo});

  final Foo? foo;

  @override
  Widget build(BuildContext context) {
    if (foo == null) return SizedBox.shrink();

    // No `!` needed because from within a final class we would get null promotion on fields.
    return Text(foo.bar);
  }
}

// In some other file...
final fooView = tester.widget<FooView>(find.byType(FooView));
// Doesn't work. Consumers outside of the class/file see `foo` abstracted as a getter
// so they can't get null promotion.
if (fooView.foo != null) print(fooView.foo.bar);

@lrhn
Copy link
Member

lrhn commented Feb 24, 2025

Can we get null promotion for nullable fields within a final class?

It's possible. It does require that all subclasses inside the same library (if any) are also final or sealed, and has the property as a final instance variable (or something the definitely throws). The promotion only applies inside that library. (Doesn't have to be just the class.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
field-promotion Issues related to addressing the lack of field promotion request Requests to resolve a particular developer problem
Projects
None yet
Development

No branches or pull requests