Typed throws

Second. If no one can tell me how decisions are made (and by which people) in this evolution process, I don't see how I should be able to bring this idea further to reality.

@minuscorp and some fellows are taking the approach of implementing a solution as a proof of concept, which is reasonable.

From my side I want to have the conceptional part solved as a whole first. We can divide things up into multiple proposals (@Chris_Lattner3) and even bring things into the language step by step, but I think we should have at least a common shared vision for this feature as a whole that is backed by conceptual soundness and the backing of Swift decision makers before starting an implementation (even if it's just a proof of concept) with that complexity.

1 Like

I’m not sure if we need to involve decision makers just yet. I don’t feel like we even have a sound system at this point.

Also, the style of the evolution is generally more about not painting ourselves into the corner, that each step can still go either way, than to have a solid 5 proposal plans ahead.

IMO, the minimum viable product should just be

And also

  • The type and interpretation of throw statements

While making sure that a few potential error propagation is congruent with the design.

Mhh, I see more of a risk of taking irreversible paths for an approach that does not take the big picture into account. Some things are hard to bring into a language later (e.g. Optional in Java). For example if sum types would still be an option, we could not introduce them easily later. They would be a substantial part of a new rethrows. So I see no problem with evaluating ideas a little bit further ahead into the future if that reduces risks.

That's exactly what I mean by why we need some decision process. Your opinion is that this would be enough for a minimum viable feature, but I'm not that convinced. Others may share your or my opinion or another one. All these opinions have their valid arguments. And now? What to do next? We are stuck. So we wait for coincidence that evolution can occur? (That's not pointed at you, I just want to express my concerns with this decision process).

That’s why I said not to paint ourselves into the corner. We do consider future proposal(s), but not to the level that everything is polished and shiny. Especially when we aren’t even guaranteed the first step.

We kind of are. Even if the core team agree with the problem statement and the approach, and I’m not saying that they currently do (I’m not even sure that I do), someone needs to implement it.

Also, most of these decisions are made by the proposers unless something is simply infeasible, which is usually pointed out very early in the pitch phase. They are usually pretty free, while of course are encouraged to take feedback into consideration.

As the one, who suggested Solution 1 to the rethrows issue, I want to make clear, why I suggested it.

the key question with rethrows, which is whether the signature of A below is equivalent to that of B or that of C

// A
func foo(fn: () throws -> Void) rethrows

// B
func foo<erased E: Error>(fn: () throws E -> Void) rethrows E

// C
func foo<erased E: Error>(fn: () throws E -> Void) rethrows Error

I think, it is equivalent to this:

func foo(fn: () throws Error -> Void) rethrows Error

which kind of is C. But important is, that this behavior must stay the same, if we don't want to break any sources.
This knowledge does IMHO just naturally lead to my solution, which doesn't break sources either.
Yes, with this solution we would need to change e.g. map to this:

func map<E, T>(_ transform: (Element) throws E -> T) rethrows E -> [T]

which is unfortunate, but I can't see any way around this.
Notice, that I don't propose, to add changes of e.g. map in this proposal, but it is good if we have the ability, to make them in the future.

1 Like

For the type inference issue I would extend solution 2 to the following:

throws functions should never be inferred to be the typed throw but the base throws, unless there is an explicitly specified typed throws. It would behave like follows:

struct SomeError: Error {}

func foo1(_ bar: () throws -> Int) {}

foo1 { throw SomeError() }
// bar has type `() throws -> Int`,
// there is no explicit type after `throws` in the declaration

foo1 { 5 }
// bar has type `() throws -> Int`,
// but we emit a warning, that no error is thrown


func foo2<T>(_ bar: T) {}

foo2 { throw SomeError() }
// bar has type `() throws -> ()`,
// we don't infer `SomeError` as the thrown type here,
// because again there is no explicit type after `throws`
// in the declaration (we consider not having an explicit
// `throws` or an explicit function type at all the same)


func foo3<E>(_ bar: () throws E -> Int) {}

foo3 { throw SomeError() }
// bar has type `() throws SomeType -> Int`,
// because there is an explicit typed throws

foo3 { 5 }
// bar has type `() throws Never -> Int` or `() -> Int` for short

Variables:

let value = { throw SomeError() }
// value has type `() throws -> ()`,
// again there was no type explicitly specified typed throws

// if we want a variable to have a typed throws, we declare it like so
let value1: () throws SomeError -> () = { throw SomeError() }


// the same applies to other types, like arrays, dictionaries, tuples, etc.
let array1 = [{ throws SomeError() }]
// array1 has type `[() throws -> ()]`

let dict1  = ["foo": { throws SomeError() }]
// dict1 has type `[String: () throws -> ()]`

let tuple1 = (5, { throws SomeError() })
// tuple1 has type `(Int, () throws -> ())`


// to make them have typed throws:
let array2: [() throws SomeError -> ()] = [{ throws SomeError() }]

let dict2: [String, () throws SomeError -> ()] = ["foo": { throws SomeError() }]

let tuple2: (Int, () throws SomeError -> ()) = (5, { throws SomeError() })

Other scenarios behave predictably:

struct Foo<T> {
    let t: T
}

let value2 = Foo { throw SomeError() }
// value2 has type `Foo<() throws -> ()>`,
// because `T` has no explicit typed throws

let value3: Foo<() throws -> ()> = Foo { throw SomeError() }
// or
let value4 = Foo<() throws -> ()>(t: { throw SomeError() })


struct Bar<E> {
    let bar: () throws E -> ()
}

let value5 = Bar { throw SomeError() }
// value5 has type `Foo<() throws SomeError -> ()>`,
// because bar has a function type with explicitly specified typed throws

This would be my solution to this problem. I think, it's a good trade-off between keeping source-stability and having expressivity.

1 Like

This is my favourite solution for

  • not breaking source compat
  • don't advancing the throws keyword (e.g. @typed throws)

But maybe the majority prefers advancing the keyword more. But I will point this out in the discussion document. Thx a lot.

We put together all the approaches we have until now here: swift-typed-throws/Typed throws discussion.md at master · minuscorp/swift-typed-throws · GitHub

1 Like

We are now three people working on the implementation, but we all have little to no experience with the Swift compiler whatsoever. This gets tricky, especially when we try to implement type-checking (the swift type-checker is the single most scary piece of code I've ever seen). I spent the whole afternoon, only trying to implement type-checking of the type after throws.

Would someone with a bit more knowledge about the type-checker volunteer, to at least give us some tips? That would already really help. Just DM me ;)

I would recommend posting your implementation related questions on #development:compiler. We can continue talking about type-checking the throws type there.

Just for let you know, we're still in development, it is a pretty big proposal to implement :smile:

8 Likes

Let me show an option though I am not sure if it is good.

Currently, it is discussed that the following two are equivalent.

func foo() throws
func foo() throws Error

In the context of "Improving the UI of generics", it can be written as shown below.

func foo() throws any Error

Instead of it, I think plain throws can be interpreted as throws some Error.

func foo() throws some Error

throws some Error has an advantage of performance because it does not require existential containers when it is specialized.

A problem of throws some Error is cases of throwing multiple types of errors in one function.

// `throws` stands for `throws some Error`
func foo() throws {
    if Bool.random() {
        throw AError()
    } else {
        throw BError()
    }
}

On the analogy of opaque result types, it seems like to cause a compilation error. However, because Error has self-conformance, any Errors can be throws as some Errors. Thus the code above can be interpreted as the following one.

func foo() throws some Error {
    if Bool.random() {
        throw AError() as any Error
    } else {
        throw BError() as any Error
    }
}

Then it could be possible to introduce throws as throws some Error without any source-breaking changes.

3 Likes

Just wanted to say thank you very much, @minuscorp, for pushing for this idea.

1 Like

I've been out of the forum and the implementation due to several health issues, but this is not an idea I want to abandon at all. Once I'm recovered I'll try to start again and get this forward. Thank you for your comprehension and support, @eneko and the rest of the supporters.

1 Like

I wish you a speedy recovery. It might interest you that typed throws came up in another thread related to rethrows and protocol conformances. There's a design challenge buried in there from me ;)

Doug

In the first post there are documents that redirect to the pitch in the thread as well as some workarounds for some edge cases that came up, just in case you didn't notice them.
Anyway I'll read throughly the thread you mention and see if something we talked about in here can help you out. Also we left a pending implementation that might be useful in the future for the first step, that was make typed throws work into the system. Later iterations would solve subsequential issues until a final solution is ready to be proposed, that was our point of view about this whole topic if this post helps in something to you.

I’m a bit late here but

At most one type of specific error can be used with a throws .

Is this rule set in stone?

I understand that “sum types” are out of scope but, in my opinion, listing types is not a “sum type” but just, well, a list of types.

I would very much like to have a list of types thrown.

I think it would simplify many use cases, e.g. the family example would not have to be:

enum FamilyError : Error {
   case kid(_ e: KidError)
   case spouse(_ e: SpouseError)
   case cat(_ e: CatError)
}

func callFamily() throws FamilyError -> Family {
    do {
        let kids = try callKids()
        let spouse = try callSpouse()
        let cat = try callCat()
        return Family(kids: kids, spouse: spouse, cat: cat)
    } catch let error as CatError  {
        throw FamilyError.cat(error)
    } catch let error as SpouseError  {
        throw FamilyError.spouse(error)
    } catch let error as KidError{
        throw FamilyError.kid(error)
    }
}

but could be much simpler:

func callFamily() throws CatError, SpouseError, KidError -> Family {
    let kids = try callKids()
    let spouse = try callSpouse()
    let cat = try callCat()
    return Family(kids: kids, spouse: spouse, cat: cat)
}

I understand this just moves the catches to the call side. On the other hand, switching over FamilyError is the same amount of code.

For Result and rethrows, the compiler could use the “natural sum type”: Search the inheritance tree until a type is found that all thrown types conform to (the search is guaranteed to end at Error):

protocol ColoredError: Error { }
class BlueError: ColoredError { }
class DeepBlueError: BlueError { }
class RedError: ColoredError { }

func f() throws BlueError, DeepBlueError, RedError {}

is equal to

func f() -> Result<Void, ColoredError> {}

How are you supposed to catch this type of typed error? The point of of throwing just one type is the same rationale that a variable can only have one concrete type, to achieve what you propose I think you'll have to cast all types individually to check which one is, and if they are enums then cast which case is. Two levels of casting seems to complex to me. I don't know about other opinions though!

Imho it would already help if that annotation wouldn't change the behavior of the compiler at all (except maybe checking wether the error-types actually exist):
Documenting which errors users of your code can expect is useful on its own, even if there are no guarantees associated with that information.

It can be a thing to expand in a future work or a second iteration, first of all to let the user get used to the new syntax (unique thrown type) but throwing for example, a list of unrelated types, gives almost as little information in the client error treatment as if it were an untyped thrown error imo