[Breaking change for Swift 6] Infer return type `Never` when every exit path throws

The idea is fairly simple, however I'm no compiler engineer so I cannot tackle the implementation side of it.

Anyways, with potentially typed throws somewhere on the horizon we should change the inferred return type for closures.

let foo = {
  throw MyError.bar
}

// currently: () throws -> Void
// after: () throws -> Never

If the complier can guarantee that every exit path throws, then it should infer Never as a default return type.

This would align well with Result<Never, MyError>.


The following example the closure is still unaffected as not every exit path throws:

let baz = {
  if someCondition {
    print("swift")
  } else {
    throw MyError.bar
  }
}

// baz: () throws -> Void

If someone would like to provide an implementation and write a formal proposal, I'd be more than happy to pass the idea over!

4 Likes

I don’t think this is desirable until Never is made into a universal bottom type. E.g. if a closure parameter expects something throwing that returns void, returning Never would be prohibitive as it is today.

7 Likes

If foo had the () throws -> Never signature as pitched, it would be your responsibility to expect the thrown error and extract or forward it properly.

do {
  try foo()
  print("never reached") // warning unreachable code
} catch {
  print("always prints: \(error)")
}

In your particular case you already said it yourself, the closure parameter expects something. The expectation of a parameter is not defined until someone writes it. I'm not arguing to remove the ability to write (() throws -> Void) rethrows -> Void. I'm arguing about the inferred default return type in case all exit paths are known to never be reached because of throw.

After a bit more thinking I guess I see your point now. You want to be able to pass () throws -> Never function when the signature is actually () throws -> Void, hence the idea of Never being the ultimate bottom type. While this would be great, it would only be an ADDITION to the language we can tackle in the future. However the pitched alignment would be a BREAKING CHANGE. We don't have many times such breaking changes can be accepted into the language. Swift 6 is one possible opportunity.

1 Like

How do you think this would function with protocols? And closures? For example, say I have this protocol and mock implementation:

protocol ThingClient {
  func getThings() async throws -> [Thing]
}

struct FailingThingClient: ThingClient {
  func getThings() async throws -> [Thing] {
    throw MockError()
  }
}

Does this work? Does that need to then return Never? Same question with replaceable closures.

let work: () throws -> Void
if condition {
  work = { /* do stuff */ }
} else {
  work = { throw MyError() }
}
1 Like

Sorry if I caused some confusion. I revised my original post to only return type inference in cases where it was not explicitly set and there is no returning exit path.

Your examples should be totally fine if we limit the idea only to 'default' type inference.

Therefore:

let a = {
  throw MyError.foo
} // Inferred as `() throws -> Never` instead of `() throws -> Void`

let b: () throws -> String = {
  throw MyError.foo
} // remains okay because the type is explicit

let c = {
  if someCondition {
    print("swift")
  } else {
    throw MyError.foo
  }
} // still infers as `() throws -> Void` as not every exit path throws

There is no justification for this in the pitch, so I find it hard to understand the point of the breaking change.

1 Like

I am not sure that it's feasible for type inference to be dependent on control flow analysis. The constraint system doesn't really know anything about control flow within a multi-statement closure (and this is also requires the constraint system to infer multi-statement closure return types from the closure body at all, which is in-progress, but hasn't been pitched yet). All diagnostics that are based on control flow (e.g. missing return in closure expected to return 'Int', variables used before initialization, etc) come from SIL.

4 Likes

IMHO, if we considered a breaking change for closure type inference, we should do it together with typed throws, as that would possibly also need a change in closure inference.

1 Like

I personally don’t like the idea of Never as a bottom type. What happens if a protocol has an initializer requirement?

protocol InstantiableAndConvertableToInt {
    init()
    func makeInt() -> Int
}

func instantiateAndMakeInt<I: InstantiableAndConvertableToInt>(from type: I.Type) -> Int {
    I().makeInt()
}

let integer = instantiateAndMakeInt(from: Never.self)

How would this work? Would it crash (which may be unexpected)?

I would entertain the idea of one-way implicit conversions between function types that return an uninhabited type and other function types as long as the parameters match.

E.g. (Int, Int) -> Never could be implicitly converted to (Int, Int) -> Double

I'm not sure if there are any changes needed for typed throws in regards to closure reference. I'm in no position to share details this on our forums, but I can at least tell there was a quick proof-of-concept implementation in the past month. If that developer would like to officially kick off a discussion, I only would be in favor of this. Typed throws overlap too many topics and from my point of view are already inevitable.

Counter question: Why is -> Void the current default for this inference?


@hborla thank you for joining the discussion. As I already said, I'm no compiler engineer and I openly state that my pitch might be based on a wrong assumption. It's just the logical thought that if there is nothing ever returned because on every exit path we would throw an error, that the return type must be inferred as Never unless it's explicitly defined.

I for my part do not understand why the inferred return type is Void unless this is just the base return type for closures.

Unless I misunderstand something, this is a slightly different topic. Never as the bottom type does overlap the current discussion, that is true, but it's additional and does not required a breaking change from my point of view.

As for what your code should do. I would say that it should theoretically compile and if the compiler is smart enough it will tell that your code will become unreachable or it will simply result into a runtime crash similar to fatalError().

Well it would be quite nice, if closures would infer their throws type:

enum MyError: Error {
    case foo
}

let closure = {
    if someCondition {
        throw MyError.foo
    }

    print("bar")
}
// closure is of type () throws(MyError) -> ()

But, AFAIUI, this could be a breaking change, because as of now closure has the type () throws -> (), which would be () throws(Error) -> () in typed-throws world.

() throws(MyError) -> Void would be a sub-type of () throws(Error) -> Void (aka. () throws -> Void for short). I don't think any code will break if the compiler started inferring the concrete error type if we had typed throws.

You're going to need a stronger motivation than this to make a breaking change. It's not enough that it might make slightly more logical sense, there would have to be some reasonably strong benefit.

You're not wrong as I agree with you about the formal proposal motivation, but that still does not answer my question on why the current inference default is Void in a closure that exits every of its paths with a throw.

In plain theory the compiler should be able to guarantee that such closure always throws, but it rejects this guarantee today. To me this is lost type information. While you can fix this manually, it's very strange to me that this isn't enforced automatically.

// can throw or return a value, but the returned value is nothing
() throws -> Void
() -> Return<Void, Error>

// always errors out / throws, never returns a successful value
() throws -> Never
() -> Return<Never, Error>

Well, historically Never didn't exist as a type and there was the @noreturn attribute instead. And it's not clear if this change would be beneficial today on its own, regardless of backwards compatibility, because of the issue pointed out above with not being able to pass a function/closure returning Never to an API expecting Void.

Edit: I understand there is a distinction between the two, as you point out above, but it's not clear to me that it's a particularly valuable one.

1 Like

In the typed throws discussion we came to the conclusion that it could be source breaking in rare situations like this (original post):

struct Foo: Error { ... }
struct Bar: Error { ... }
var throwers = [{ throw Foo() }] // Inferred as `Array<() throws -> ()>`, or `Array<() throws(Foo) -> ()>`?
throwers.append({ throw Bar() }) // Compiles today, error if we infer `throws(Foo)`

I understand your point, but I think this is something the compiler will need to handle if we introduce typed throws. Since the type is always inferred and never was explicit in your example I would assume that the compiler should consider all sources which lead to the inference of the throwing type (this includes append in my opinion).

Similarly if we would apply this to exit paths, if one exit path throws Foo and the other Bar, the most common super type is Error so the type inference would fallback to Error. I do not want to derail the conversation with something like throws(Foo | Bar), this is too much off-topic already. As for my part, I prefer the most common super type as the inferred super type.

That’s a big “if”

That is true, but I think that we should settle this question relatively soon because it affects so many things in the language.
I really dislike that we have accepted concurrency into Swift before deciding on the topic of typed throws, because it has so many aspects where typed throws are already half baked in, but not fully, which makes it a little bit awkward in case we accept typed throws eventually, but as well if we don't (take for example all of the AsyncThrowing*Sequence types, withTaskGroup(of:body:) vs. withThrowingTaskGroup(of:body:), etc.).