Implicit member expressions for function-typed parameters
- Proposal: SE-NNNN
- Authors: Jonathan Gilbert
- Review Manager: TBD
- Status: Pitch
- Implementation (draft): [Sema] Implicit member syntax through function types by gistya · Pull Request #90295 · swiftlang/swift · GitHub
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) // ❌
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:
- Perform existing lookup against
E(current behavior). - New step: if
Eis a function type(P...) -> R, now perform a member lookup formemberagainstR, 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. - 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.bto be a value of typeX— butX.bis 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 findsX.b: (Int) -> X, bindingP == 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
@resultBuilderbodies, 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.