Skip to content

Generalize record literal inference to handle union types - #4714

Open
eernstg wants to merge 1 commit into
mainfrom
spec_record_unionFree_jun26
Open

Generalize record literal inference to handle union types#4714
eernstg wants to merge 1 commit into
mainfrom
spec_record_unionFree_jun26

Conversation

@eernstg

@eernstg eernstg commented Jun 30, 2026

Copy link
Copy Markdown
Member

This PR adds the notion of 'union-free type schema' derived from a given type schema (as a slight generalization of the 'union-free type' in the language specification) to the feature specification of records, and uses it to provide a better context type to each component of a record literal during inference.

This is, in principle, a breaking change. It should be language versioned. The first action will be to use the specification update to guide an implementation and assess the breakage. The PR may then be landed if the breakage is negligible; in case of significant breakage, the way ahead will need to be discussed further.

@lrhn lrhn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume all non-record types are handled elsewhere.
(And patterns are not an issue because this is only about expression contexts.)

@leafpetersen

Copy link
Copy Markdown
Member

@eernst and I discussed this today, and I'm concerned because I don't think this is particularly a record specific thing. Poking at the implementations, we're currently fairly inconsistent with our treatment of context types here. I think this should be pulled out to a general issue, and we should figure out what is currently being done (and if possible why, I think we discussed something like this in at least some places). @stereotype441 may have more context. Here are some tests showing inconsistent treatments - we seem to strip FutureOr for most but not all literals, and no patterns that I have tried.

import 'dart:async';

class A {
  A.mk();
}

void t<T>(FutureOr<T> x) {}

T foo<T>() {throw "nope";}

void test() {
  t<A>(.mk());
  t<(A,)>((.mk(),));  // error
  t<List<A>>([.mk()]);
  t<Set<A>>({.mk()});
  t<Map<A, A>>({.mk() : .mk()});
  t<A Function()>(() => .mk()); // error  
  t<int>(foo()..isEven); // error
  
  {
    FutureOr<(int, int)> x = (3,4);
    switch(x) {
      case (var a, _): a.isEven; // error
    }
  }
  {
    FutureOr<List<int>> x = [3];
    switch(x) {
      case [var a]: a.isEven; // error
    }
  }

}

@eernstg

eernstg commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

I do think we have an explicit rule for many of these cases:


With t<A>(.mk()), the dot shorthand feature specification explicitly says that union types are erased from the outside of the context type in order to determine the static namespace where the dot shorthand is resolved:

If a type scheme S:

  • has the form C or C<typeArgs> where C is a type introduced by a declaration D which must therefore be a type-introducing declaration, which currently means a class, mixin, enum or extension type declaration, then S denotes the declaration D.
  • has the form S? or FutureOr<S>, and the type scheme S denotes a declaration D, then so does S?/FutureOr<S>. Only the "base type" of the union type is considered, ensuring that a type scheme denotes at most one declaration or static namespace.

With t<(A,)>((.mk(),)), the record feature specification says that only a record type schema is decomposed in order to find a suitable context type for each component:

If K is a record type schema of the form (K1, ..., Kn, {d1 : K{n+1}, ...., dm : K{n+m}}) then:
... [decompose]
If K is any other type schema:
... [the context type for each component is _]

In other words, this is explicitly specified to not erase the outermost union types, so it fails.


For t<A Function()>(() => .mk()), 'inference.md' says that

Function literals which are inferred in an non-empty typing context where the context type is a function type
[will use it, and other types including FutureOr<...> will yield the context _]

So we're again specifying explicitly that no union types are erased, and we could again add a rule that erases the outermost union types from the context type before we start looking into the function type signature.


For t<int>(foo()..isEven) we just use FutureOr<int> as the context type, which seems to be fine because it succeeds by passing FutureOr<int> as the actual type argument to foo.


The remaining cases are collection literals, and they are able to erase the union types in order to obtain a relevant context type schema. However, this seems to be achieved as follows:

Subtype constraint generation includes the following rule:

  • If Q is FutureOr<Q0> the match holds under constraint set C:
    • If P is FutureOr<P0> and P0 is a subtype match for Q0 under
      constraint set C.
    • Or if P is a subtype match for Future<Q0> under non-empty constraint set
      C
    • Or if P is a subtype match for Q0 under constraint set C
    • Or if P is a subtype match for Future<Q0> under empty constraint set
      C

The collection literals are recognized as having types List<...>, Set<...>, Map<...>, and case 3 (P is a subtype match for Q0) succeeds.

The same case does not succeed for a record literal, even in the case where the context type is FutureOr<R> where R is a record type.

In the implementation, performSubtypeConstraintGenerationForRightFutureOr does not recognize record literals, possibly because record literals do not use generic type inference or subtype constraint generation at all.

With FutureOr<List<int>> xs = [];, the analyzer seems to create a GenericInferrer with an unknown type variable E, and attempts to solve the subtype constraint List<E> <: FutureOr<List<int>>.

This subtype check is what triggers 'type_analyzer_operations.dart', which strips off FutureOr and generates the constraint E <: int.

Record literals are not generic classes with type parameters to infer. So the analyzer never initializes a GenericInferrer and never runs subtype constraint gathering (performSubtypeConstraintGenerationForRightFutureOr) for the literal as a whole.

Instead of solving constraints, when analyzing a record literal, the analyzer simply inspects the downward context type to see if it can extract individual field context types to pass down to each expression in the record.

In 'record_literal_resolver.dart', the analyzer directly checks if contextType is a RecordType:

    RecordTypeImpl? _matchContextType(
      RecordLiteralImpl node,
      DartType contextType,
    ) {
      if (contextType is! RecordTypeImpl) return null;
      ...

The front end (CFE) does the exact same thing in 'inference_visitor.dart': if (typeContext is RecordType && ...).

It looks like record literals bypass the generic solver where FutureOr erasure is implemented.

The fix might be in the implementation: performSubtypeConstraintGenerationForRightFutureOr or something called from there should be adjusted such that "P is a subtype match for Q0" can succeed when P is the type of a record literal and Q0 is a record type with the same shape.

On the other hand, if "P is a subtype match for Q0" is not intended to include record literals and record types then it looks like the fix would be an easy change in a function like _matchContextType. Similarly for function literals.

@eernstg
eernstg force-pushed the spec_record_unionFree_jun26 branch from 0ca8f10 to aff121a Compare July 17, 2026 15:38
@eernstg

eernstg commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

@leafpetersen, what do you think about the approach taken in this PR? It introduces a specific rule about the context type provided to the components of a record literal. We may need to do a similar thing for function literals, but that can be done separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants