Status check: Typed throws

I hope this also forces us to bring non-exhaustive enums to non-resilient Swift libraries. That would be the other way for libraries that need to work in runtime-limited environments to provide source-stable yet expandable error types.

16 Likes

Ah okay, I guess I misunderstood it then. I had the feeling it meant that () -> Void meaning () throws(Never) -> Void is an "unfortunate" change. I personally like that direction.

But I understand now that the way "non-throwingness" could be determined, shouldn't be limited to just Never.

I donā€™t have a concrete example to provide off the top of my head, but I am confident people will eventually bump into problems without it. If I find a good concrete example Iā€™ll add it to the thread.

I am describing a generalization that goes further than non-throwing being equivalent to throws Never. For example, throws MarkerType would also be non-throwing where `MarkerType is declared as:

enum MarkerType: MarkerProtocol { }

Obviously this is a silly example, but in I believe there are cases where it would be important in terms of usability of generic libraries. The most important point is that absence of this design element could impact users of a library (forcing them to ā€œhandleā€ errors that can never be thrown).

3 Likes

i often find it valuable to define my own uninhabited types, because Never is a shared namespace, and over the past year or so iā€™ve been gradually moving away from adding conformances and extension members to Never in favor of adding things to custom empty enums.

i donā€™t do it too often for error types, i usually do it for ā€œtype constantsā€ (e.g. Limit.One, Days.Monday, etc.) in some overly-clever API, but i can see how it could be valuable for error types for similar reasons.

2 Likes

I think there would be formal issues in the type system with treating throws(Uninhabited) as canonically equivalent to nonthrowing in all cases, since that would create a weird (to my non-type-system-expert eyes) type checking problem unifying (A) -> R with (A) throws(E) -> R; the non-concrete type (A) -> R suddenly becomes generically equivalent to forall E: Uninhabited & Error . (A) throws(E) -> R. However, maybe there could be an implicit conversion from throws(Uninhabited) to nonthrowing when we can see statically that the type is uninhabited, though that also has the problem of introducing a cyclic subtyping relationship.

If nothing else, it seems like we could at least let you call a function with an uninhabited error type without a try and outside of a throws or do { } catch {} context.

6 Likes

Is there a case when this equivalence don't hold? Seems like all instances of those types fundamentally equivalent (due to the fact that such instances do not exist)?

let a: Never1
let b: Never2

a ā‰” b
(which means you can use them interchangebly)

But
Never1.self ā‰¢ Never2.self

All uninhabited types are isomorphic, but there may be interesting distinctions to draw using different types to represent different reasons for being uninhabited. Since they have no actual values, and any code that claims to have a value is by definition unreachable, it is trivial to "convert" one to any other type:

func convert<T>(from: Never) -> T { switch from {} }

but the only reason to do that is to fill in some type mismatch between components.

7 Likes

I think of it in the same dichotomy as describe by @ellie20, where either you have a domain-specific error (that you deal with) or you have general errors, and that any attempt to enumerate "more than one" error is poor API design.

It's clear that a proposal will need to provide extensive arguments for this view point. It's going to come up a lot:

fetchUser is an API that depends on several different subsystems, which are likely to change over time, and decoding of fairly arbitrary data. This is a prime use case for untyped throws, and I think it's wrong to seek syntactic sugar for it.

This implies a whole lot more for runtime type checking, because you have to be able to answer at runtime whether a particular type is uninhabited. That's not something we compute now at runtime, and it's not a trivial thing to compute for a generic enum because you have to look at all of the types of the associated values. Why? Here's a super-contrived example:

func f<E: Error>(_ value: Any, errorType: E.Type) { 
  typealias Fn = () throws(E) -> Void
  if let value as? Fn {
    print("Yes, it's a function that throws a \(E)")
  }
}

enum GE<T: Error>: Error {
  case one
  case two(T)
}

func g() { }

f(g, errorType: GE<Never>.self)

This would have to determine (at runtime, if we're not specializing f) that GE<Never> is an uninhabited type in order for Fn to collapse down to a non-throwing () -> Void and match the type of g. Maybe we can do that, but let's be clear that generalizing from Never to "all uninhabited types" is actually a lot of complexity.

I had not thought of this, and had reflexively assumed we would ban rethrows(SomeError), but you have a good point!

Perhaps. I'd like us to consider inferring thrown types of closures as well, which is a more significant breaking change than the one for general catch clauses.

I hadn't thought of this, either. I can certainly see how it fits in the model, but I'm having a hard time coming up with a use case for it.

Implementation concerns aside, I do not think we should go down this route. Swift doesn't allow inference of result types for philosophical reasons: the important aspects of a function's interface are explicitly specified in its declaration, so you never need to inspect the body to understand how to interact with it. That same logic holds for typed throws as well.

The former can use typed throws, the latter should continue to use untyped throws.

Untyped throws relies on the existential type any Error, and existential types---which are heavy on type metadata and heap allocation---are not part of the Embedded Swift subset as defined in the prospective vision.

Doug

12 Likes

I agree that we don't want to infer error types at the type system level and implicitly propagate that information across functions. It would however be a useful optimization that should be straightforward to implement once we have the pieces in place, to specialize a function that only throws one type of thing to use the ABI for throwing only that thing in the optimizer.

2 Likes

Oh, the optimization is absolutely fair game, and this is a great candidate for function signature optimizations like the one you describe. But I don't think it belongs in the language model.

Doug

5 Likes

The spelling could be different, for example:

fun throwsA() throws _ {
  throw A()
}

This is the most important part, so happy to hear that is feasible.

I donā€™t have one handy off the top of my head, but it seems like something that could well come up in a library design sooner or later.

IMO Decorated<T> should have some annotation to help the compiler understand that when T is uninhabited the type itself is uninhabited, e.g.

@conditionallyUninhabited(checkedProperties: "error")
struct Decorated<T: Error> {
  var error: T
  var decoration: Decoration
}

rethrows could be expressed as

func map<E: Error, R>(_ f: (T) throws(E) -> R) throws(MappedError<E>) -> MappedResult<R>

I just want to chime in and say that this feature concerns me and I don't think some people realise the implications of what they are asking for. Having a heavily layered codebase where you can anonymously bubble up errors deep within the system for a higher level to deal with where appropriate is one of the great strengths of swift. If we move away from that it makes larger codebases much more difficult to maintain and less flexible.

I'm all for using types, it's great for correctness and I couldn't go back to not using them. But errors are inherently telling you something incorrect has happened and should be an escape hatch to quickly get back to a place which can handle them. We shouldn't need to be layering error type handling at every single layer.

If this continued to get pushed through then I just hope it's optional as I think mandatory error types will make the language much more clunky to use.

4 Likes

We're planning to introduce some additional type layout properties as generic constraints, such as BitwiseCopyable for types that have no reference counting or deinit logic, and it seems like Uninhabited would be another reasonable candidate for one of those. When we have visibility into a type's layout, then we should be free to also look at the type layout to determine its habitability.

5 Likes

With typed throws we could eliminate some holes in Result

extension Result {
  @inlinable
  public func get() throws(Failure) -> Success {
    switch self {
    case let .success(success):
      return success
    case let .failure(failure):
      throw failure
    }
  }
}

That way a Result<String, Never> would not throw on the get(). (obviously this would be a breaking change and perhaps should be spelled as a new thing... perhaps a property value to mimic Task)

10 Likes

It will absolutely remain optional, and I think your concerns are well-founded. This is why I'm being so stubborn about "there shall only be one thrown error type", with no affordances for specifying multiple error types or providing a union, because if you aren't in a domain where there is exactly one, you should be using the existing untyped throws. I think a lot of the desire to make typed throws featureful is coming from the assumption that most throwing functions should have typed throws, and I think the exact opposite is true: typed throws is the right tool for a narrow set of places where there's only one way in which things can fail, and untyped throws is the right default everywhere else.

Doug

20 Likes

Canā€™t you cast from () -> Void to () throws(Whatever) -> Void regardless?

This has put my mind at rest somewhat, thank you! I still fear that if this gets implemented it will be abused, but thats down to the community to learn that lesson the hard way I imagine.

2 Likes