Type inference for optional-chained assignment expression closures

Here's a fun little snag I hit—what does the following code print?

struct S {
    var x: Int
}

var s: S? = S(x: 0)
let f = {
    s?.x = 1
}

print(type(of: f))

My expectation was () -> () but because the assignment operator returns () and because optional-chained assignments are semantically closer to something like s?.(x = 1), this closure actually has type () -> ()?.

I encountered this when I was trying to pass a closure similar to f above as a completion parameter:

func doSomething(_ completion: () -> ()) {}

doSomething(f) // error: cannot convert '() -> ()?' to '() -> ()'

notably, this works fine:

doSomething {
    s?.x = 1
}

so it was surprising to get an error when I factored the closure out into its own expression.

Maybe there's nothing to be done here that wouldn't be too source breaking, and of course there's plenty of other situations where splitting subexpressions out results in different type inference behavior, but this felt like a particularly subtle one that took me a moment to figure out.

4 Likes

Interestingly you can "fix" it via explicit type declaration:

    let f: () -> Void = {
        s?.x = 1
    }
    doSomething(f) // ok

(I'm using Void instead of () as I found "() -> ()" quite weird looking.)

2 Likes

The one use case for this is that you can use it to tell if the assignment went through or not. I don’t think I’ve ever seen someone actually do that, though.

7 Likes

Oh, nifty! That's a cool use, though also have never seen it used like that.

Yeah, I'm not suggesting that there's anything formally wrong here, just that it's probably more likely that someone wants the closure to be typed () -> () in this case. If the user were to write, say

let val = s?.x = 1

then val should definitely be inferred as ()?, and if the closure were written as

let f = {
  return s?.x = 1
}

that should also probably be inferred as () -> ()?. It's just the weird intersection of the somewhat obscure behavior for optional-chained assignments and implicit return for single-expression closures that causes this sharp corner.

3 Likes

Yeah, I guess this is the inevitable consequence of implicit return here. Prior to that feature being implemented in the language, I'd bet let f = { s?.x = 1 } was inferred as having type () -> (). Ultimately, though, if we want return s?.x = 1 to behave the way it does, there's not really a rationalization that would make it more consistent for an implicit return to behave differently...

6 Likes

I suppose this is roughly equivalent to the following also-invalid code:

var arr = [0]
let f = {
    arr.removeLast()
}

doSomething(f) // error: cannot convert '() -> Int' to '() -> ()'

so I guess the 'consistent' rule would be that implicit returns for single-expression closures always prefer returning () absent other type context when the expression result is discardable. Of course, that still requires implicitly considering the result of an assignment to be discardable, but IIRC the language is already opinionated about that in some diagnostics.

Anyway, probably not worth changing type inference behavior for this one small edge case.

Yeah, I guess one could make such a rule, but it would (silently) discard information as compared to the status quo.

As both rules (yours and the status quo) would be possibly unexpected for some subset of users and difficult to diagnose, and the workarounds are fairly discoverable and trivial either way, I think I’d err on the side of not silently discarding values even if it’s perhaps less often the desired inferred behavior.

6 Likes