[Pitch] Implicit member expressions for function-typed parameters

Implicit member expressions for function-typed parameters

Introduction

Swift's implicit member syntax ("leading dot") lets you write .foo in place of MyType.foo whenever the compiler is able to infer MyType, with very few exceptions.

The exception at issue here is when a parameter expects a closure (P) -> T, in which case the compiler surprisingly refuses to let you omit MyType, requiring a fully-qualifyed MyType.foo. This happens even when MyType is fully determined by inference and .foo is an enum case with associated value(s), or any static member that produces a MyType instance.

This proposal extends implicit member resolution so that, when the expected type is a function type, member lookup also considers the function's return type and allows implicit member syntax if that return type matches the expectation.

Swift-evolution thread: TODO

Motivation

Consider an overloaded initializer that accepts either a value or a function producing that value — a very common shape for builders, DSLs, and dependency-injection helpers:

struct Foo<T> {
    init(_ t: T) { }
    init<P>(_ t: @escaping @Sendable (P) -> T) { }
}

enum X { case a; case b(Int) }

let a1 = Foo<X>(.a)        // ✅ uses init(_: T), .a resolves to X.a
let b1 = Foo<X>(.b)        // ❌ error: member 'b' expects argument of type 'Int'
let b2 = Foo<X>(X.b)       // ✅ uses init<P>(_:), X.b is the function (Int) -> X
let b3 = Foo<X> { X.b($0) }// ✅ explicit closure, equivalent to b2

The asymmetry is surprising. X.a and X.b are both members of X. For the no-payload case .a, the leading dot works. For the payload-carrying case .b, which is exactly the case where Swift would treat the unapplied case as a function (Int) -> X, the leading dot fails — and the only fix is to use the fully-qualified X.b, even though it's obvious the compiler has no issue inferring X.

From the programmer's seat, adding X here communicates nothing new. The contextual type is already Foo<X>; T is already X. Writing X.b rather than .b feels like a weird incantation to work around an incomprehensible compiler inconsistency. Why should such a ceremony be required for some members of the same enum and not for others?

A concrete DSL case

Personally, I hit this issue while creating a SwiftUI-inspired declarative DSL for a statechart orchestration library.

In our "Conway's game of life" sample app, we declare valid transitions between states, triggered by events (standard practice for state machine setup; see full code here). The events that trigger transitions are represented by enum cases:

enum LifeEvent: Hashable {
    case clear              // no payload
    case toggleCell(Int)    // carries a cell index
    case running
}

The goal of the DSL is for transitions to be declared with clean dot-syntax, however we get a surprising and unhelpful compiler error due to the described issue:

XTransition(on: .clear, to: .running)      // ✅ 
XTransition(on: .toggleCell, to: .running) // ❌ 

:cross_mark: Member 'toggleCell(x:y:)' is a function that produces expected type 'LifeEvent'; did you mean to call it?

No sir, I did not mean to call it. I know it's a function, and that's why we have this init overload:

    public init(on event: EventID, to target: StateID) {
        node = Schema.TransitionNode(event: event, target: target, guard: nil, action: nil)
    }

    // XTransition's overload for a function type param:

    public init<Payload>(on caseInit: @escaping @Sendable (Payload) -> EventID, to target: StateID) {
        self.init(on: CasePath(caseInit), to: target)
    }

(see full code here)

This feels surprising. Why can't the compiler find the overload without appending the type LifeEvent, which it obviously already inferred, and which it did not require in the case of .clear in the same context?

This honestly feels broken or incomplete, so it compelled me to pitch this proposal. The cases the user can abbreviate and the cases they can't should not differ only by an implementation detail (such as whether an enum case has a payload).

From an authoring standpoint, this kind of issue makes our DSL feel flaky, and most people would not understand it's a limitation of Swift and not the DSL.

Impact on newcomers to Swift

Newer programmers will be even more surprised/confused by this compiler error.

The official Swift documentation does not mention that an enum case with associated values can be referenced as an unapplied function of type (Payload) -> MyEnum. The enum chapter at docs.swift.org covers associated values, raw values, and dot syntax, but it never says an enum case is effectively a function type.

Proposed solution

When resolving a leading-dot member expression .member against an expected type, if the expected type is a function type (P0, P1, ...) -> R, the compiler additionally looks for a static member named member on the return type R that, when referenced as an unapplied declaration, has a type compatible with (P0, P1, ...) -> R.

For enum cases this means: .toggleCell against expected type (Int) -> LifeEvent resolves to LifeEvent.toggleCell, whose unapplied form is exactly (Int) -> LifeEvent. The existing rules for the non-function case are unchanged, so .clear against LifeEvent still works as before, and there is no behavior change for code that doesn't currently hit this gap.

Critically, the return-type lookup produces candidates that are strictly lower priority than every existing implicit-member candidate. Whenever a member exists directly on the expected type (the present-day rule), that candidate is selected and the new candidate is never reached. The new candidate can only win in positions that are errors today, where no direct candidate is viable.

Concretely:

let b1 = Foo<X>(.b)                         // now resolves to init<P>(_:), .b == X.b
XTransition(on: .toggleCell, to: .running)  // now resolves to LifeEvent.toggleCell

The rule generalizes naturally beyond enum cases to any static member of R whose type is a function returning R — for example a static factory method static func make(_:) -> R.

Detailed design

(see draft PR: [Sema] Implicit member syntax through function types by gistya · Pull Request #90295 · swiftlang/swift · GitHub)

Implicit member lookup currently proceeds (roughly) as: given an expected type E, look up member as a static member / case of E and form the corresponding implicit member expression.

This proposal adds a fallback path with these steps:

  1. Perform existing lookup against E (current behavior).
  2. New step: if E is a function type (P...) -> R, now perform a member lookup for member against R, considering only members whose unapplied reference type is compatible with (P...) -> R. Enum cases with associated values qualify (their unapplied form is the case constructor). So do static methods and static stored/computed properties of function type.
  3. New step: the candidate(s) from step 2 enter the constraint system as a disjunction choice ranked strictly below every candidate from step 1, so they are only selected when no step-1 candidate is viable. (So as not to impact any existing codebase.)

Note: the return-type lookup requires that the step-2 candidate leaves no residual effect on the surrounding constraint system when it is not selected. It may only participate as a lowest-priority disjunction choice, and is fully unwound on backtracking (just like any discarded overload choice would be). Given complete backtracking, introducing this new candidate won't change the type variables inferred for existing code in which a step-1 candidate already succeeds.

We also have that the return type R may be generic, and only determined by inference. E.g.: in our Foo<X> example above, the R == X inferrence is due to the explicit generic argument. Lookup participates in constraint solving in the same way the direct case does today; it does not require R to be known a priori beyond what the existing solver already establishes.

Interaction with overload resolution

In the motivating Foo example above, both initializers are viable for .b:

  • init(_: T) would require .b to be a value of type X — but X.b is not a value, it's a constructor, so this candidate is rejected as it is today (rejected by step 1 above).
  • New behavior: init<P>(_: (P) -> T) with the now finds X.b: (Int) -> X, binding P == Int, so this candidate succeeds (accepted by step 2 above).

This example resolves unambiguously.

The strict-lower-priority ranking also resolves what would otherwise be the awkward case: a type R that has both a static value member: R and a static function (or case) member: (P) -> R, matched against a parameter overloaded on value vs. function.

Because the value member is a step-1 candidate and the function-via-return-type member is step-2, the value interpretation always wins, and no currently-unambiguous code is made ambiguous. The same ranking decides the defaulted-argument case, e.g. when a static func make(_ x: Int = 0) -> R is reachable both as a value (make()) via step 1 and as a function (Int) -> R via step 2. Here the value interpretation is selected in step 1 as would currently happen (so existing behavior is preserved).

What about the situation where a user wants to use the function-type overload in a situation where implicit member syntax would default to the value-type member? This proposal does not change the current behavior in that case. As now, that user would use the explicit member syntax to incur the function-type resolution. (Though it seems best to recommend follow-up work to better document this, or perhaps improve the syntax clarity; see Future Directions below.)

Residual edge cases

Although the strict-lower-priority ranking neutralizes the ambiguity and overload-selection hazards (a viable direct candidate always wins), two residual concerns remain and are not resolved by ranking.

These concerns stem from the candidate existing at all, rather than from which candidate is selected:

  • Type-checker performance: every implicit-member expression against a function-typed parameter now introduces an additional disjunction choice, even when it ultimately loses. Consider expressions that already strain the solver, such as large @resultBuilder bodies, deeply overloaded operators, and big literal collections. This proposal might add the straw that broke the camel's back by raising the branching factor just enough to push a borderline expression into the "compiler is unable to type-check this expression in reasonable time" shadow realm. This is the cost most relevant to the motivating DSL use case, where result-builder bodies are common, and warrants benchmarking during implementation.
  • Inference interaction: as described in Source compatibility, the additive guarantee rests on the step-2 candidate being fully unwound on backtracking. This is an obligation on the implementation and should be verified.

The current diagnostic for Foo<X>(.b) is:

Member 'b' expects argument of type 'Int'

This wording is itself a symptom of the issue. The compiler found X.b, noticed it needs an Int, and gave up rather than considering the function-typed overload. Under this proposal, that path succeeds, so the diagnostic disappears for the cases that now compile. For cases that still don't type-check (no compatible member on R), the existing diagnostics are preserved.

Source compatibility

Since this proposal simply turns a compile error into a successful resolution, it is source-compatibility neutral. The step-2 candidate is ranked below every step-1 candidate, so no code in which an existing candidate is viable changes its selected overload. Whenever today's rules resolve an implicit member, they continue to resolve it the same way. The new candidate can only be selected in positions that are ill-formed today.

However, due to the "Residual Edge Cases" described above, it might not be the case that fixing this issue will have zero observable effects on inference in all codebases. Adding any new candidate(s) to a disjunction is, in principle, not "nothing." That said, we are unaware of a program that exhibits this, and constructing one requires the solver to bind a shared type variable inside the step-2 branch and fail to retract it on backtracking. Rather than assert this cannot happen, the proposal states the implementation obligation that rules it out (see Detailed Design): the step-2 candidate must be fully unwound on backtracking and leave no residual constraint. Under that obligation, currently-well-formed programs retain their inferred types. This is a checkable requirement on the implementation, not a claim about every conceivable solver state.

This is something to bear in mind and seek confidence on while the fix is gated behind a compiler flag.

ABI compatibility

None. This is a type-checking / name-resolution change with no runtime or ABI-level component.

Implementation notes / prior discussion

The basic form of this rule was suggested by Jordan Rose on the Swift forums, where he characterized today's behavior as: .foo means "look in the expected type for a static member foo producing that type," and observed that the expected type here is (P) -> T, which has no members. The needed addition is a rule that, "if the expected type is a function type, look in the function's return type for the member name instead." Jordan noted no technical objection to such a rule, only that it would need to go through evolution — which is the purpose of this pitch.

Alternatives considered

Do nothing; require the explicit type

Users write LifeEvent.toggleCell / X.b for the function-typed cases. This is what makes the API feel inconsistent: abbreviation is available for some members of a type and not others, with the dividing line being an implementation detail (payload vs. no payload). For DSLs whose entire value proposition is the leading-dot surface, this is a significant ergonomic loss.

Library-side workarounds

A library could add explicit value-typed members or wrapper factory functions to route every case through the value overload. This is boilerplate that scales with the number of cases, has to be maintained in lockstep with the enum, and pushes the language's inconsistency onto every API author who hits it rather than fixing it once.

Restrict the rule to enum cases only

The proposed rule could be narrowed to consider only enum cases on R rather than any static member of function type. This is simpler to specify but arbitrarily excludes static factory methods that have the same shape and the same motivation. The broader rule is a strict superset and no harder to implement in the constraint system, so the proposal takes the general form.

Future directions

  • Multi-argument cases / curried members already get covered by the new rule as proposed (the expected function type can have multiple parameters).
  • The same return-type-checking logic could in principle extend to other
    contextual positions where a function value is expected and its return type is determined, beyond direct parameter passing.
  • Currently, explicit member syntax is the way to force step-2 if a viable step-1 candidate exists, but it's very unintuitive that should be the case. To address this, follow-up work could add additional clarifying syntax to allow for more intuitive, explicit selection of the function type resolution (step-2) when a value type resolution (step-1) would normally be chosen by the compiler. For example, .toggle(&) to indicate the function-type as opposed to the invocation of the function.
  • Follow-up work to clarify the function-type behavior enums in the official Swift documentation seems warranted, regardless of the outcome of this proposal.
6 Likes

I think this answers your question:

Indeed, I’d be very wary of introducing a new form of inference which requires a disjunction, especially when the cost of not doing so (having to add a single token) is trivial. The leading dot member syntax is already a common source of performance problems, strange behaviors, and bad diagnostics, so any extensions proposed here would need to be designed to also improve the existing model first, to be viable at all.

4 Likes

I don't see this as an exception. My understanding of dot syntax is it has two conditions: the identifier must be a member (in the sense of syntax, not value) of the inferred type and that member's value must be of (or implicitly convertible to) the inferred type.

In your motivation section's example, a is a member of type X and that member has type X. However, b is a member of type X but that member has type (Int) -> X.

Recall that we have two overloads also:

    public init(_ value: X) {
        // ...
    }

    public init<T>(_ func: (T) -> X) {
        // ....
    }

.a is a member of type X and resolves to the first overload.

.b is a member of type (Int) -> X and should resolve to the second overload (but doesn't).

The proposal is simply to let .b resolve to (T)->X instead of erroring out.

Agreed, it's why I called out this concern. Don't want to assume anything up front, though. Planning to do thorough analysis on some builds of the PR branch.

An implicit member expression depends on both the namespace that contains the identifier, and the identifier's value's type in that namespace.

enum X { case a; case b(Int) }

The X namespace contains four identifiers:

  • a is a member of namespace X, and X.a evaluates to a value of type X.
  • b is a member of namespace X, and X.b evaluates to a value of type (Int) -> X.
  • self is a member of namespace X, and X.self evaluates to the metatype object (a value) representing X.
  • Type is a member of namespace X, and X.Type is a type (not a value). Specifically, X.Type is the metatype of X, and the type of X.self.

When I said “b is a member of type X”, I was referring to membership in the X namespace.

The (Int) -> X namespace only has two members:

  • self is a member of namespace (Int) -> X, and ((Int) -> X).self evaluates to the metatype object representing (Int) -> X.
  • Type is a member of namespace (Int) -> X, and ((Int) -> X).Type is a type (not a value). Specifically, ((Int) -> X).Type is the metatype of (Int) -> X, and the type of ((Int) -> X).self.

So b is not a member of the (Int) -> X namespace; ((Int) -> X).b is not valid as an expression or as a type. Therefore .b cannot be a valid implicit member expression in an (Int) -> X context.

That's how I understand the situation. I was not surprised by the error in your Foo/X example. I do understand why you want it to work in the LifeEvent example (brevity/DRY).

I find that the following modification to Foo/X works:


struct Map<In, Out> {
  let transform: (In) -> Out
}

struct Foo<T> {
  init(_ t: T) { }
  init<P>(_ t: @escaping @Sendable (P) -> T) { }
  init<P>(_ m: Map<P, T>) { }
}

enum X { case a; case b(Int) }

extension Map where In == Int, Out == X {
  static var b: Map<Int, X> { .init(transform: X.b) }
}

@main
enum Main {
  static func main() {
    let a1 = Foo<X>(.a)        // ✅ uses init(_: T), .a resolves to X.a
    let b1 = Foo<X>(.b)        // ✅ uses init(_: Map<P, T>), .b resolves to Map.b
    let b2 = Foo<X>(X.b)       // ✅ uses init<P>(_:), X.b is the function (Int) -> X
    let b3 = Foo<X> { X.b($0) }// ✅ explicit closure, equivalent to b2
  }
}

Presumably you don't want to manually write the b member of Map. Perhaps a peer macro attached to X could generate it.

2 Likes

You caught my imprecise wording. Let me rephrase the last comment for clarity:

.a is a member (of type X) and has type X.

.b is a member (of type X) and has type @Sendable (Int) -> X. (Right?)

IMHO the compiler should be able to infer X.b without it having to be explicitly spelled out, even if that's just a sprinkling of sugar here.

So b is not a member of the (Int) -> X namespace

You don't find it surprising that X.b works then? Or that the compiler can't simply infer X here?

Interestingly compiler gives a much more telling error here if we get rid of the first init overload:

struct Foo<T> {
    ///init(_ t: T) { }
    init<P>(_ t: @escaping @Sendable (P) -> T) { }
}

enum X { case a(Void); case b(Int) }

let a1 = Foo<X>(.a)        // ❌ Generic parameter 'P' could not be inferred
                           // ❌ Type '@Sendable (_) -> X' has no member 'a'
let b1 = Foo<X>(.b)        // ❌ error: member 'b' expects argument of type 'Int'
                           // ❌ Type '@Sendable (_) -> X' has no member 'b'

"Could not be inferred" sounds like an engineering problem to solve :D

This error clarifies your point that .a and .b are not members of those namespaces.

What's confusing to me is, are X.a and X.b members? If not, why do they not throw the same error? If so, then why can't the compiler simply infer X here, since the compiler clearly knows that X is the generic type, and X is the return type of these function types?

Seems worth the "ol' college try" to enhance the compiler with this capability and see how it goes.

I was not surprised by the error in your Foo/X example. I do understand why you want it to work in the LifeEvent example (brevity/DRY).

I hope this proposal can enhance the expressiveness, consistency, and clean syntax of Swift.

I find that the following modification to Foo/X works. <\snip> Presumably you don't want to manually write the b member of Map. Perhaps a peer macro attached to X could generate it

That's a cool workaround and a macro could probably generate it.

I'd be much more open to macros if there wasn't such a severe impact of custom macros on build times, and if you didn't have to add swift-syntax as a heavyweight dependency to your Swift package just to use them. That might sound negative/harsh, but I just feel macros right now are only justifiable as a power-tool for advanced use-cases, not as a bandaid for a gap in compiler inference capability that (hopefully) can be fixed at the source.

Not that I'm super optimistic about the prospects of resolving the disjunction concern that Slava and I shared, but going to try anyway.

I think @mayoff's point is that "implicit member expressions" have rules about what should be inferred and that what you describe is not that--i.e., that your pitch is for inference that is outside what the leading dot means in Swift and not a question of whether it can be done technically.

2 Likes

BTW Rob- thanks for providing this workaround in lieu of a possible fix. I am very appreciative of that. For now at least, it's totally fair to leave it up to consumers of a DSL (like the one in the example) to implement a macro if they wish, either rolling their own or using a possible secondary package we could offer. (Or, they can write their own Map extension, or have AI do it in that Xcode 27 :smiley:).

I'll be including the Map workaround in our state machine library with an acknowledgement to your suggestion and a link to this thread for context.

It occurred to me that the AI-assisted future makes such boilerplate as the Map extension seem a lot less like a problem. Rather than needing macros, it could just be a skill. One of the main goals of the declarative state machine DSL that motivated this proposal is to have an API that makes it easier to reason about and properly govern LLM-assisted codebases, so perhaps that goes hand-in-hand.

Will report back once the PR is in a state that can be stress tested...

Thanks for bringing up the topic of rules about what the compiler should infer. Apologies in advance for the long reply (just trying to be thorough, and hopefully write material that would become part of the official proposal).

Adding to the description of existing behavior that Rob provided, below are two sections to explain the current proposal:

  • Rules about inference: What I have been able to gather about the apparent rules of implicit member expressions from documented sources, with comments on how the proposed solution relates to them. (Please reply with any additions/corrections)
  • Solution modeled on optional handling: How the proposed solution was modeled on (and in a minor way, improves upon) the existing handling of Optionals.

Rules about inference

  1. Swift's API design guidelines Fundamentals:

    Clarity at the point of use is your most important goal.

    Clarity is more important than brevity.

    Comment: the proposed solution merely eliminates redundant information from what is required to write a valid statement. E.g.: Foo<X?>(X?.some) can now be written as Foo<X?>(.some).

  2. Swift's official docs on Implicit Member Expressions:

    An implicit member expression is an abbreviated way to access a member of a type, such as an enumeration case or a type method, in a context where type inference can determine the implied type.

    If the inferred type is an optional, you can also use a member of the non-optional type in an implicit member expression.

    Comment: the proposed solution would simply need a third section here. E.g.:

    If the inferred type is a function type, you can also use a member of the function's return type in an implicit member expression, as long as the member has that function type. For example:

    enum Suit {
        case hearts, spades
        case wild(Int)
    }
    
    let makeWildSuit: (Int) -> Suit = .wild
    

    Here, .wild refers to Suit.wild, whose type (Int) -> Suit matches the type of makeWildSuit. A static method of the return type that has the same function type can be used the same way.

  3. Swift's official docs on Chained Implicit Member Expressions:

    Implicit member expressions can be followed by a postfix operator or other postfix syntax listed in Postfix Expressions. This is called a chained implicit member expression. Although it's common for all of the chained postfix expressions to have the same type, the only requirement is that the whole chained implicit member expression needs to be convertible to the type implied by its context.

    Comment: The current proposed solution already supports chained implicit member expressions, thanks to essentially being a copy of the implementation of the scenario where "the inferred type is an optional" (see details below). As well, this feature satisfies "convertible to the implied type" by identity—it never uses the convertibility latitude this rule grants. The enum-case constructor (Int) -> X is the context type, and the type of the final chained expression:

    enum X {
      case a
      case b(Int)
      var asFunction: (Int) -> X { { X.b($0) } }
    }
    
    let f1: (Int) -> X = .a.asFunction // valid under this proposal
    let f2: (Int) -> X = X.a.asFunction // valid in current Swift
    

    Here we can genuinely combine the existing chained implicit member expression feature from SE-0287 by @Jumhyn with the new syntax from this proposal (verified to work in the PR).

    Note that because function types are non-nominal, they cannot have new members added by extension, limiting them to the final position of postfix expressions (not counting .self).

  4. SE-0287: Extend implicit member syntax to cover chains of member referencesAllow chained member references in implicit member expressions followed a "mental model" rule:

    the mental model that many users likely have for implicit member syntax, [...] boils down to a simple lexical omission of the type name in contexts where the type is clear. I.e., users expect that writing:

    let one: C = .zero.incremented

    is just the same as writing

    let one = C.zero.incremented

    the model mentioned earlier for implicit member expressions: anywhere that a contextual type T can be inferred

    Comment: it follows from the "mental model" above that:

    let one: C = C.zero.incremented

    ... is redundant.

    Then, this is also redundant:

    let f2: (Int) -> X = X.a.asFunction

    ... and so is this:

    let b2 = Foo<X?>(X?.some)

    ... therefore the current proposal fits within the same rule about how inference should work that motivated SE-0287.

    Additionally, simply adding the ability to infer the function type makes the proposed solution obey the other rule, "anywhere that a contextual type T can be inferred," by definition.

Solution Modeled on Optional handling

When I developed this proposal, I started by looking at how Optionals are handled as an exception to the "member of the contextual type rule". Consider:

struct Foo<T> {
    init(_ t: T) { }
    init<P>(_ t: @escaping @Sendable (P) -> T) {}
}

enum X { case a }

let a1 = Foo<X?>(.a)        // ✅ ... even though:
let a2 = X?.a               // ❌ Type 'X?' has no member 'a'

Inside SEMA, Foo<X?>(.a) works because of the DeclViaUnwrappedOptional path that "looks through" to find the return type of the function, (Payload) -> X, which is produced by Optional<X>.some, even though .a is not a member of it.

The proposed solution copies that existing "rule-breaking" DeclViaUnwrappedOptional path and applies it more broadly to any function type that returns the contextually expected type—including this scenario also involving Optional:

let b1 = Foo<X?>(.some)        // ❌ Member 'some' expects argument of type 'X'
let b2 = Foo<X?>(X?.some)        // ✅ (why should X?.some work but not .some?)

... which is no longer an error under the proposed solution:

let b1 = Foo<X?>(.some) // ✅ (nice!)

For detail, ImplicitMemberOnFunctionType pathway for this example is essentially:

  • choose(.some)
  • where func choose(_: X?) -> Int / func choose<P>(_: (P) -> X?) -> String)
    selects the function overload —
    let picked: String = choose(.some) type-checks.
  • such that .some resolves to X?.some : (X) -> X?,
    binding P = X,
    (identical in mechanism to how it'd work for .b in the other examples).

In Conclusion

Please reply if I'm reading any of this incorrectly or you are aware of any other rules that would argue against (or support) the proposal. Thanks.

OK, done.

Performance Optimizations Added

I added some performance optimizations to the PR. Below are metrics on passing 20 leading-dot calls into a value-and-closure overloaded init, like the builder/DSL API pattern in the examples.

Without the new feature flag enabled (but with the performance optimizations), using .a and X.b type syntax:

before after
disjunction choices attempted 40 20
constraint scopes 152 92
typecheck-expr time 2.11 ms 1.77 ms (−16%)

With the new feature flag enabled, using the proposed new .a and .b syntax:

call disj. choices scopes time delta
Sink(.b) ×20, payload cases 40 → 20 152 → 92 −6%
XTransition(on: .toggleCell, to: .running) ×20 40 → 20 330 → 190 −14%
explicit E.b spelling (control) 20 → 20 unchanged unchanged
operator chains, variadic-generic args (controls) unchanged unchanged noise

Bottom line:

The old syntax X.b is no faster than the new syntax .b with these optimizations. Many other existing dot-syntax uses will now get a speed boost as well, even with the feature flag off. (Unless we want an additional feature flag for the speedup?)

How the optimization works

For each overload, we run the same cached member lookup the solver itself would run, into that overload's parameter type, plus "shape checks" that never resolve any types:

  • a name that doesn't exist can't match
  • a bare no-payload case can't be a function
  • an unapplied constructor can't be a plain value
  • function conversions preserve arity.

If we can prove a choice can't be matched, we drop it, and the solver never attempts it.

The rule that makes this safe is, the analysis must be all-or-nothing per argument. That is, if any overload's parameter can't be analyzed (e.g. Double, String, existentials... anytime lookup has no work to do), then we keep today's path.

(Without that rule, we might suppress an "ambiguous use of" error or silently flip which overload wins... as I learned the hard way.)

What the problem was

The optimization resolves an issue in today's compiler where, when you pass a leading dot f(.b) to an overloaded call against f(_: E) and f<P>(_: (P) -> E), the constraint optimizer determineBestChoicesInContext learns literally nothing from the argument. It treats .b simply as a fresh type variable with no bindings. Then the unlabeled-unary "fast path" gives up, and a parameter like (P) -> E makes the whole disjunction "unsupported." So now the solver has to attempt every overload at every call site, fully exploring branches that were never going to work. Even though the compiler knew the member name the whole time, it never uses it in this situation.

(See the draft PR for the code, I probably left too many comments in there but it's a draft. Hopefully this can make the new feature viable, as per your requirement to improve the existing model first.)