sdk icon indicating copy to clipboard operation
sdk copied to clipboard

[breaking change] Make null-aware elements/entries not allow `void`-typed expressions.

Open lrhn opened this issue 2 weeks ago • 9 comments

Change Intent

Disallow an expression with static type void as the operand of a null-aware list/set literal element or map literal key/value entry.

Front-end and analyzer will report an error for ?e as a list or set literal element or a map literal key or value if e has static type void, whether the context type is void or not.

It's currently not an error if the context type of ?e is void.

Justification

(See also: https://github.com/dart-lang/sdk/issues/58456.)

The language specification does not allow expressions with static type void in most positions, only a select allow-list of locations allow expressions with static type void, with some being conditional on the context type being void too.

In Collection literals, List/Set elements and Map key/value entries were never on that list, but implementations have allowed expressions with static type void there if the element/key/value type is void. The language team has chosen to keep this behavior, considering it consistent with other cases where a void value is used in a void context. So <void>[print("Yup!")] will be put on the allow-list. Not because it's a good idea, but it's no worse than <void>[]..add(print("Yup"!)).

Null-aware elements and null-aware map key/values were also never on that list, and they were also allowed by implementations. That's inconsistent with not allowing a void-expression in a position where its value is used for anything. Just like print() == null is disallowed, the implementations should be updated to enforce that <void>[?print("not OK!")] is an error even when the element type is void. That is currently not an error, making this a breaking change.

The purpose of the void type is to represent a function call that has no result value, but embedded in a language where every function must return a value. You can cast a function returning int to a function returning void, and the latter should only be used in places where nobody looks at the returned value. Most void-typed functions return null, but fx due to up-casting, they might not. It should never matter to program correctness which value a void- typed expression actually has at runtime. And therefore control flow shouldn't depend on a void-typed expression like it would as operand of a null-aware element.

Impact

Expected to have low impact.

There are likely (provably) examples of code that has a void-value in a void-typed collection, which is another reason for keeping this allowed. Some may even predate type inference and have changed type from List<dynamic> to List<void> when type inference was added to the language. There has been a lot of time to accumulate examples of this, it's been possible to write a collection literal since collection literals were introduced, which would today be an example of this.

Having null-aware elements with a void typed expression is much less likely. Null aware elements were introduced in Dart 3.8, and you have to write a deliberate ? to opt in to doing the check, which also suggests that you expect a useful nullable type, and not void.

That means there will be far fewer occurences than for collection elements in general, all added recently and by someone who was aware of typing. It's still possible that someone wrote var list = [?foo()] and foo turned out to return void instead of what the author thought. In that (expected to be rare) case, it's probably a bug, and reporting it should be a benefit to everybody.

Mitigation

We can make the change language versioned, even though it is really a bug-fix. If there is very little breakage, it's probably not worth doing that.

The first step should be to implement the change, and see how much impact it actually has, for example by running it on the internal repository.

Depending on the intial impact, we can take one of the following approaches, from least to most work:

  1. No or almost no breaks: Just release as a bug-fix in the next minor version SDK increment. Users can check whether their code will be affected by testing them with a dev-release of the SDK.
  2. Few breaks of some severity: Same, but guard fix with experiment flag. Don't release until people have had time to adapt. At that point release as breaking change (not language versioned).
  3. More widespread, but not severe, issues. Introduce as only an analyzer warning first, giving people time to fix issues before changing it to an error. (More work to initially distinguish existing errors from new warnings/coming errors, which will be the same code path when it's all errors.)
  4. Even more issues, unlikely to be fixed soon. Introduce with experiment flag and release as language-versioned change.

If we're doing option 2, we might as well go for option 4. Our experiment logic is tuned for language-versioned changes, so it may be more work to make an experiment flag guarded change not be language-versioned. (Or not, will see what's possible if it's needed.)

Change Timeline

If possible, just release as a breaking change ASAP, in 3.11 or 3.12, whichever is still achievable.

I hope, and 85% expect, that this is what we want.

If ecosystem looks like it needs more time to adapt, release a warning or experiment-flag-guarded error ASAP in dev-releases.

If language versioned, we can release the change at the same time. If not, we'll have to decide when the ecosystem is ready enough.

Associated CLs

Proof-of-concept CL: https://dart-review.googlesource.com/c/sdk/+/464703

lrhn avatar Nov 26 '25 17:11 lrhn