[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.

5 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.

3 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).

EDIT: After additional research today, adding an "Addendum" section at the end of this post to discuss several key findings. Will update the pitch document to provide a comprehensive overview later.

Adding to the description of existing behavior that Rob provided, below are two three 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.
  • EDIT: Addendum—July 2: Discussion of an additional rule and ongoing work; new diagnostics added to PR.

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).

EDIT: Addendum—July 2

Additional rules discussion: TypeChecker.md

TypeChecker.md in swift/docs has that:

The Swift language contains a number of features not part of the Hindley-Milner type system, including constrained polymorphic types and function overloading, which complicate the presentation and implementation somewhat. On the other hand, Swift limits the scope of type inference to a single expression or statement, for purely practical reasons: we expect that we can provide better performance and vastly better diagnostics when the problem is limited in scope.

Comment: a primary goal of this proposal is to avoid running afoul of this rule. This proposal must not open a Pandora's box of expressivity with currying, point-free-style expressions, or self-referentiality.

Fortunately, the proposed solution holds the line and indeed "limits the scope of type inference to a single expression or statement." Here, the leading dot means what it has always meant: a value of the contextual type. This proposal doesn't change that; it only updates the compiler to properly respect the fact that an unapplied enum-case constructor or static factory is a value of function type:

  • .a with XX.a, a value of X.
  • .b with (Int) -> XX.b, a value of (Int) -> X -- because the case constructor X.b literally denotes a function (Int) -> X

Nothing is wrapped or looked-through; the reference is a function value. ∎

Ongoing work

As an ongoing effort now, a systematic effort to exhaust possible "gotchas" is underway:

  • currying edge cases
  • diagnostics vis-à-vis structural ambiguity
  • stress testing of lookup (guard against "point-free" style issues)

So far this testing has two results:

  • The proposed solution (in current PR) preserves the rule that dot syntax names a value of the contextual type by looking at the return type. It never inspects or captures the parameters. As a result, it has not been able to be fooled by "receiver/point-free/uncurry" types of traps. They all require the parameter list to be in play, but it never is.
  • A couple of gaps were found in diagnostics, which have now been corrected on the PR. Essentially we are just porting over the pre-existing diagnostics already given for certain scenarios with the explicit forms of the same member expressions, which this proposal now allows to be expressed implicitly.

Will continue to check various "trap" scenarios and if anyone has a clever one that can refute the proposed solution, I'm happy to close out the PR and withdraw the pitch.

Can provide the list of "traps" already tested in a followup post if desired.

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.)

Let me re-reply and summarize the above, to see if we can move forward with these discussions or if I should close the pitch.

My extensive testing has seemed to demonstrate the PR to be technically sound, meeting the high bar set by @Slava_Pestov of meaningfully improving the current model rather than merely working around its limitations. The core remaining obstacles are not implementation issues but @xwu's questions about whether the pitch should even be considered if it seeks to evolve the underlying rules of what implicit member expressions are allowed to mean. You precisely articulated the existing Swift syntactic rules for dot syntax and implicit member expressions, but it's unclear to me why they should not be open to evolution proposals.

That leaves three open questions that could still block progress: whether Swift Evolution is willing to entertain a pitch that adds to the rules about what should be inferrable; what those rules actually are in relation to implicit members and leading-dot syntax (not the syntax rules but the rules that govern their rationale); and whether this proposal violates those rules or simply extends them in a coherent way. I'd note that my finding has been that the original rationale for today's restrictions appears to have been technical rather than philosophical, and that those technical concerns are addressed and respected by the implementation. Supporting documentation of official language guidelines and my testing have already been provided in earlier replies, and additional stress/trap testing docs can be provided.

Finally, I ask for a decision as to whether to move forward: either by engaging in a principled language-design debate over the proposed new rule, or, if the new rule seems fine philosophically, then by proceeding to a thorough technical review of the PR. Depending on the decision, I'll either update the pitch with the accumulated findings or close it. The proposed new rule so far (WIP):

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.

Just to clarify, what I meant there is that the existing implementation of leading dot inference (and the related key path root inference) consists of special case checks spread throughout the compiler. It is hard to reason about and suffers from known issues with performance and ambiguity.

Your PR adds a block of new code to CSOptimizer to implement a filtering optimization. I suspect that this would be better done in bindings instead, but in any case adding a new solver optimization on top of the existing design is not what I had in mind in terms of improving the existing design.

Another concern I have with your PR is that I’d like to get rid of DeclViaUnwrappedOptional entirely as part of cleaning up leading dot syntax, and adding a new case that is similar makes that project more difficult.

If general, I want to add that with every new form of inference or implicit conversion, there is inevitably a long tail of issues with solver performance and ambiguity that only reveal themselves later, sometimes much later. I don’t think it is wise to entertain generalizing the current leading dot syntax until more progress is made toward solving the existing known issues in this area.

7 Likes

Thanks Slava, it's fair to require some cleanup so we can reason about the impact of these changes. I'd be happy to rework the PR and help with the refactor effort you're proposing.

RE: the CSOptimizer work—it is fully separable from the feature, so I'll pull it out. We can revisit that later if desired... it could be worthwhile if it can improve performance generally. I'd like to better understand how you're picturing this via bindings.

RE: dropping the DeclViaFunctionResult approach—this is the one I most want to get right, since I don't want to add to the refactor you have planned. I only modeled it on DeclViaUnwrappedOptional because that was the existing precedent. If that approach is deprecated, I'd like to align the function-result case with the new architecture. Can you share anything about the removal plan/issue? Hopefully the revised proposal can avoid a new OverloadChoiceKind. I've got time to do some of the legwork on this.

RE: the long tail of inevitable issues from adding new inference—avoiding that is a major goal of the proposed design. I understand that the existing state of the solver on implicit dot makes it hard to reason about the impact of the proposed changes, which is why the design is built to be closed rather than open-ended (see below), and why it sits behind an experimental flag while that's confirmed.

EDIT—PS—If you can please provide some examples of a bug and which new kind of inference caused it, that would be extremely helpful to lock in a plan to make the codebase more resilient against those and improve the test suite.

RE: the question of generalization—the proposed new rule does not add generalization to dot-syntax. There are four self-referential requirements that pin it against generalization:

  1. b: T is a static member of X
  2. T is (...) -> X
  3. (...) -> X is the inferred type of expression E
  4. E is X.b.

If and only if all four conditions are met, then the new rule will allow E to be expressed as .b (new third type of implicit member expression).

Details:

This design prevents generalization because each of those requirements is defined in terms of the others, so there's no axis left to relax. Requirement 2 pins the member's type to the inferred function type, while Requirement 4 says X.b must already be a legal expression usable in that context under the conversions the dot already applies. These two requirements make the implicit form .b a pure abbreviation of X.b, introducing no meaning that wasn't already writable, and allowing no new conversions.

The only new step is identifying the base (X) as the return type of the inferred function type. That's a deterministic extraction, not a search... a function type has exactly one result type, so nothing new becomes ambiguous in figuring out what X is.

These requirements also prevent the kinds of situations that I was worried about—e.g.: .red matching () -> Color, .member matching Task<Color>, .x matching Optional<() -> T>. These all get rejected (happy to paste the exact errors).

The proposed rule adds no new types of conversions. Nor does this feature add any "look through the structural wrapper" style generalizations. What's added is purely a narrowly bounded lookup: candidates are "members of the return type," selected are "those already typed as the context." I measured the solver's baseline cost with no optimizer help as a constant per-dot overhead that scales linearly, resolved in a single solution attempt (no new disjunction interaction, no backtracking).

See this github gist with the "adversarial playground" illustrating how the proposed solution resists generalization and avoids adding new conversions.

Example of how the proposed change does not alter conversion or generality:

protocol TextStyle { var name: String { get } }
struct Bold: TextStyle { let name = "bold" }

// SE-0299 style: a factory exposed on the protocol, constrained to a concrete type.
// Its type is (Int) -> Bold.
extension TextStyle where Self == Bold {
    static var bold: (Int) -> Bold { { _ in Bold() } }
}

func render(_ makeStyle: (Int) -> any TextStyle) {
    let style = makeStyle(12)
    print("rendered:", style.name)
}

Current Swift:

render(Bold.bold)   // prints: rendered: bold

Bold.bold is (Int) -> Bold, and render's parameter expects (Int) -> any TextStyle. So it works via function-result covariance + existential erasure (Bold becomes any TextStyle). No leading dot involved, the conversion already exists.

Proposed Change:

render(.bold)       // prints: rendered: bold

Identical resolution and identical runtime behavior. The only difference is that .bold elides the Bold prefix.

With the new proposed feature-flag off, render(.bold) fails with type '(Int) -> any TextStyle' has no member 'bold'; feature on, it resolves to exactly the Bold.bold above.

I've updated the PR to use a plain Decl instead of DeclViaFunctionResult.

In the last commit I migrated Optional to use a plain Decl instead of DeclViaUnwrappedOptional. (Easily separable to a different PR.)

Let me know if additional cleanup work is needed for the PR to be viable, and/or if you want the Optional refactor moved to a separate pitch/pr.

Once I have your blessing to proceed then I'll rewrite the "pitch" based on the settled-on implementation and hopefully we can proceed to Proposal stage.

Summary of implementation now:

New function-type leading dot:

  • Add an experimental feature ImplicitMemberOnFunctionType gating the new behavior.
  • An earlier revision introduced a bespoke DeclViaFunctionResult overload-choice kind, which had to be named in ~13 places across the solver, IDE, and diagnostics. Pitch feedback indicated this to be undesirable. Refactored it as below.
  • return-type lookup produces an ordinary Decl overload choice based at R's metatype... same identical choice the solver forms for an explicit R.member, exactly as R.member already did
  • uses same mechanism as isFallbackMemberOnUnwrappedBase() to rank strictly below any member found directly on the expected type and ensure no impact on existing code
  • read by isDeclViaFunctionResult() via SK_UnresolvedMemberViaFunctionResult
  • performs well since look-through candidates carry a strictly-worse score, so they land in a lower disjunction partition that DisjunctionStep::shouldStopAt properly stops at (so has speed zero impact on existing code; measured solver time on existing code is zero across several adversarial examples)
  • companion optimizer change (determineBestChoicesInContext) analyzes implicit-member arguments by name during disjunction checking, so an implicit-dot argument to an overloaded call costs the same as an explicit spelling (i.e. no performance degradation from existing explicit syntax for these function types)

Optional leading dot cleanup:

  • applies the same demotion from DeclVia... to plan Decl to the pre-existing DeclViaUnwrappedOptional
  • independent of the feature and can be split into its own PR on request
  • optional-unwrap now reads as Decl, so it's excluded by predicate rather than by kind

Plus new tests. Diagnostics fixes from prior version of implementation still apply.

Note: ambiguity behavior is proper. E.g.:

enum X { 
   case none 
   case some(Int) 
} 
  
func f(_ value: X) { print("value overload") } 
func f(_ closure: (Int) -> Int?) { print("closure overload: \(type(of: closure))") } 
func f(_ closure: (Int) -> X) { print("closure overload: \(type(of: closure))") } 
  
f(.none) // prints "value overload"
f(.some) // prints "closure overload: (Int) -> X" 

// Note: (Int) -> X resolves because of the existing behavior where Swift prefers
//       the less-generic candidate. In situations where there isn't a less-generic 
//       candidate, this PR preserves the standard "ambiguous use of..." error.