[Pitch] Implicit member expressions for function-typed parameters

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 X → X.a, a value of X.
  • .b with (Int) -> X → X.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.