[Pitch N+1] Typed Throws

Yes, for users! If the possible thrown types are not documented and subject to change, it doesn't do users a service to pretend like there's a strong contract for the thrown error only to have it break down the line. I don't feel it should be taken for granted that more information is always better for the user and that trading against specificity in favor of flexibility is hurting the user in favor of the author—being transparent about what is fixed and what is subject to change is helpful, not harmful.

There's definitely a spectrum of how breaking a "breaking change" is (both in terms of raw magnitude, but also in terms of a more squishy 'reasonableness'). Any change in behavior has the potential to break someone who was relying on it. The same can be said for changing the underlying type of an existential or opaque return value—after all, someone could have been dynamically casting to observe the true type—and yet we don't generally expect API authors to vend entirely private, purpose built wrappers to eschew dynamic type information.

Similarly if someone is casting an undocumented error and observing specifics about the returned value, that feels like more reasonable break than someone who saw NSError declared in the function signature and opted to rely on that.

Of course, if someone is already documenting "this function always throws FrobulationFailure when it fails," then sure, getting a compiler error when that changes seems strictly better for both clients and authors, and people who want to get specific about their error types should make use of typed throws instead. But I don't think "people could already be relying on undocumented implementation details" is a great reason for defaulting to typed throws.

7 Likes

Sure, but the question is why they're not documentable?

Sometimes it's a challenge to exhaustively document the failure modes, given the possibility new ones will arise in future. But that's still very different from not documenting any error cases.

And if there truly is nothing useful the API can promise about the error type, then the benefit of throwing (versus e.g. returning an optional) diminishes a lot. Yes, it gives you some options for more conveniently failing up through the stack, but that's about it. Which is fine if that's just the reality of the situation, but it's not a good situation so one should try to avoid it if possible. It may also be a sign that the function in question doesn't understand its own failure modes (or at least hasn't explained them clearly to callers), which is never good anyway.

Even for functions that don't throw it's important that callers understand the function's failure modes. What inputs will make this function break? What preconditions are required? What environmental factors are relevant? If you're not throwing then the best you can currently do is put those into your human-readable documentation, but if you are throwing it's a good opportunity to document it in a way that's also machine-readable and can help you find bugs (e.g. the compiler won't let you just make up fictional enum cases at your throw site, they have to be properly defined and therefore at least minimally documented).

Indeed. But there's two aspects to this we should recognise:

  1. If callers subvert abstractions, then nominally it's on them if it breaks. It's no different to if they rely on some undocumented behaviour of the function - if no such behaviour was promised, then arguably it's fair game for the implementation to change or revoke it. But

  2. People have stuff to get done. If APIs are incomplete, poorly documented, buggy, etc, then you just have to be realistic about how people will react, like it or not. I don't think there's any benefit, let-alone moral high ground, to saying "well I never explicitly promised my API would do this thing [that you have no practical choice but to rely on]".

So it's a give-and-take, a compromise. You give me a bad API, I "abuse" it by your definition, but I say you started it with the bad API to begin with. It's not even about who's right or wrong - it's about everyone in this situation ending up sad or angry.

To put it another way: advising people to default to untyped errors is tacitly encouraging them to not think about failure modes. Whereas encouraging them to try to use more specific error types gets them thinking about that. We need to be clear that they might indeed want to use some level of abstraction - maybe even all the way up to any Error - but it should be with reason. Forward-compatibility can be a perfectly good reason (but also doesn't necessarily mean any Error, it might just mean an open enum).

1 Like

I think you're going too far towards argumentum ad absurdum here. There's no need to invoke XKCD here when the uses of Error I've outlined are not only how people are using it right now, but the expected and only way the feature is supposed to be used by design. Unlike opaque return types, which are an explicit choice to hide internal implementation details, you're supposed to cast Errors to concrete types to extract useful information. Aside from basic error messaging that's the only way to make them useful. Like many Swift features, typed errors eliminate an entire class of bugs (incorrect casting) and allow the compiler to verify proper handling.

2 Likes

At this point, typed throws seems inevitable and this seems like the most straightforward and reasonable approach. It does make me want sum types even more though.

4 Likes

The part of discussion about usecases of rethrows was remarkably useful and exhaustive. But now it's again about good API design practice and how good/bad (un)typed throws are. And the thing is whatever one might say will be opinionated somehow. Swift has always had the ability to express typed errors, even before Result<>. It was a bit inconvenient, but still. In this sense the proposal doesn't add something that was previously fundamentally impossible. It closes the gap between Result-like types and throws, and makes throws less opinionated. (Which is also an opinion, of course).
But from the practical perspective what this implies? What questions does it raise?

  • Union types - spawned another thread
  • rethrows incompatibility - discussed
  • Are there functions in stdlib that can't benefit from typed throws?
  • How to move towards typed throws while maintaining ABI (technically)?
  • What functions in stdlib would be good candidates to adopt typed throws (apart from rethrows ones).
  • Conditional types - too marginal to discuss (I guess?), but still an interesting topic.
  • Generalization of Uninhabitable types - wanted
  • Limited support for any Error in embedded for errors whose layout fits existential container.
  • And many more other topics I don't know about...

So maybe discussion about these topics will be more productive than opinion exchange about API design. Not because API design principles regarding errors are not important, it's just all the opinions are already well discussed several times.

3 Likes

I think the consensus here seems to be almost universally in support of typed throws; it’s more of a discussion on when it’s useful rather than if it’s useful.

Personally, I’ve been fairly convinced from the arguments here that many APIs will benefit from typing their Errors – not least of which because it’s the only way that embedded/performance-annotated Swift will be able to use these APIs.

However, It seems to me that there is a sentiment here that says ‘typed throws, always’ and it’s on that point I find myself unconvinced. And without understanding why we need something it’s hard to say what we need. (I'm open to being convinced.)

Specifically, the further up the call stack you get, the less utility typed throws seems to have, and the more potential for unnecessary complexity. I think this comes down to the fact that while the function of a returned type is unary between caller and callee, a thrown Error could come from anywhere down the call stack and can fill multiple roles from control-flow, system diagnostics, user diagnostics, to simple success/failure notification.

It seems there’s a continuum that runs from the point the Error was thrown, right up through potentially multiple application, and even system, levels. And it seems that depending on at what point on that continuum the Error is caught the more or less likely it is to fill a particular role; Errors caught closer to the point that they’re thrown are more likely to be recoverable and used for control-flow, Errors at the other end of the continuum are more likely to be in an unbounded, often unrecoverable, set of other Errors used to surface system or user diagnostics, or simply to signal failure.

I do think this is worth discussing this in some way, as I think it helps frame the rationale for the supporting features that are required to get the best from typed errors; for example, does a union type serve to simplify error-handling, or would it promote unwieldy attempts to shift a snowball of strongly typed errors up the call-stack?

Furthermore, documenting this will serve as a guide to developers on when and how they should use typed throws; whether or not a union (of a union, of a union) of Error types is necessary in their own applications, or if they're better off sticking with untyped throws.

That's a long way of saying I think it's hard to talk about the design of something without considering the bigger picture, and API design certainly fits within that.

1 Like

Documenting failure modes is different than documenting precise semantic constraints about the thrown value when those failure modes are triggered. I agree that if there are well-defined preconditions that must be satisfied to successfully complete an operation, the API author ought to note that in the docs, but I don't think it then follows that the correct thing to do is to document a specific error that is thrown in this case, and then require the user to dynamically inspect the error value to determine how to recover. Rather, if an error is being thrown that there's an clear precondition and/or recovery path for, that's an indication that the author should provide a separate API for verifying that precondition, and the client should make use of that API directly.

Concretely, I would personally discourage writing code that looks like this:

func writeData() {
  do {
    try fileWriter.write(data, to: url)
  } catch let e as NoSuchFileError {
    fileManager.createFile(at: url)
    retryWriteData()
  } catch { ... }
}

in favor of:

func writeData() {
  do {
    try fileWriter.write(data, to: url, createIfNotExists: true)
  } catch { ... }
}

And if I as the API vendor wasn't providing the createIfNotExists: API (or fileExists(at:) or something) so that the only way to catch the "file doesn't exist" case was dynamically inspect the thrown error, then sure, that's on me for not giving my clients the tools they need to get stuff done.

Yeah, I totally agree that error handling via casting is not equivalent to casting away opaque return types, I'm only trying to drive the point home that there's a spectrum of reasonableness when it comes to "what behavior might clients be relying upon?" If you're casting to well-documented error types that the API has agreed it will return in particular circumstances, then yeah, a responsible library vendor shouldn't be changing those error types/values without a major version bump.

OTOH, if you're casting to error types for which no contract is present and deriving information critical to your program's control flow based on implementation details of the thrown errors, I would be hard to pressed to blame the library vendor for 'breaking' clients if they change some of those implementation details.

Yeah, if we are going to introduce typed throws then IMO a necessary part of the proposal is strong, opinionated guidance on how and when we expect the feature to be used (which, of course, might then inform the feature design). Hopefully this guidance can make its way into the API design guidelines.

2 Likes

In practice, this approach leads to TOCTTOU (time-of-check-to-time-of-use) vulnerabilities. Sometimes the only way to see if an operation will succeed is to try the operation. Filesystem operations like creat are the canonical example of this.

Filesystem operations can also fail in myriad ways, some of which come about as new features like sandboxing are added to the OS. That’s why I also think they’re the strongest case for not strictly constraining errors to a closed set.

3 Likes

Yes, but you also don't avoid this by catching a "no such file" error and then some time later responding to that error, and "there's no file at the location you're trying to write to" is in my view a different class of error than "something else is modifying the filesystem underneath you" (the latter being much more exceptional and much less recoverable than the former). IMO the right balance is that APIs should provide affordances to check ahead of time for the failure modes that have relatively clear actions a user might want to take, and clients should try to reserve error handling for relatively coarse-grained recovery mechanisms like error reporting/logging, retries, crashing, passing up the stack.

Agreed.

Overall, I'm pretty happy with the proposed recommendation for only using typed throws when you truly control the full universe of thrown errors, the full universe of potential clients, or are entirely generic over the error type.

2 Likes

You might be misunderstanding (or at least under-appreciating) the danger of the "check first" anti-pattern.

I don't have a good list of CVEs handy, but this anti-pattern has generated its fair share. The Wikipedia article, Time-of-check to time-of-use, provides a decent-enough summary of its dangers and mentions a few notable, recent examples of its exploitation.

It might be that a given person's use of Swift isn't really concerned with this - maybe their environment really is very stable and carefully controlled and has nothing that can be exploited (or used to compromise other systems). But that's of course not a valid assumption or requirement for Swift in general.

1 Like

Actually I'd say that the further up the call stack you get the more utility typed errors have. It's just also true that the more capable those error types get, the more unwieldy they become. If I write an app that has a single error type that encapsulates all errors from every system it interacts with, it can be extremely useful, as I can both rely on that error type everywhere and then handle extremely exact failure states. Some random part of my app wants to know the exact retry interval my last HTTP 429 error returned from one particular API I use, I can do that without having to dig through layers of erased errors. Of course, having such a massive error type could easily be overwhelming so it's more likely you want several, still typed, top level errors from the various systems in your app. This generally works well if your systems are cleanly separated and you don't need to represent the same error info in multiple areas.

Personally, in app code I see little downside to typing errors all the time. Not only does it force me to know what kinds of errors my various systems can return but it will also let the compiler verify when error conditions change out from under me, which doesn't happen if I just accept Error everywhere. At the least it allows me to opt into precise error checking while allowing me to easily keep ignoring everything I don't are about.

In that case I'd expect the library to hide their internal error type so I can't cast to it at all. I've never seen any library do this, as Error is fairly useless on its own.

3 Likes

This doesn't seem quite relevant to me. The the timing issue is just as present in the "respond to error" version I wrote out as it would have been in the "check first" version. Both are attempt to detect the "no such file" situation and then (later) respond with a targeted intervention (i.e., create the file). And moreover, neither situation ignores the possibility of the filesystem state changing in the background. Even if you check for the file presence ahead of time, you'll still catch the "no such file" error when it changes out from under you at write-time.

However, in the version of the API that promises to throw a NoSuchFileError when the file is missing, we're committed to ensuring that our implementation always throws NoSuchFileError in this circumstance. We must be sure that we never call out to, say, some other library in our implementation which may throw a different error when it encounters a missing file (at least not without first checking file presence ourselves). It's really easy for an error contract to introduce a dependency on the implementation details that is regrettable down the road, and I feel strongly we should not encourage users to default to that.

This would fall within guideline (2) in the proposal, no?

  1. In code that stays within a module or package where you always want to handle the error, so it's a purely an implementation detail.

For users who want to exhaustively handle all their errors I agree the 'risk' is fairly low. But I also don't think that translates to "we should encourage users to type all their internal throws functions by default."

IME, the most common lifecycle of a custom error type is that it starts with a neat set of possible failures, and then eventually gains an ".other(Error)" or ".unknown(Error)" case as the implementation evolves and it's realized that most clients don't actually care about exhaustively understanding the entire universe of errors that the implementation could actually produce.

3 Likes

IME, this is usually due to having to pass through a throwing layer, or otherwise not wanting to put the extra effort into propagating the type, whether through Result or another mechanism, or recovering the type from the previously erased layer. Few people choose .unknown(Error) because it's good design, it's just all they can do without heroics.

3 Likes

This is exactly the reality that informed Swift’s typeless error design. Error cases are special in how they tend to encompass all sources of error h underneath them. I understand how types could be useful in adding information, but I simply think folks who want to constrain errors are misguided about how error conditions work and evolve in reality.

Therefore, if Swift does gains typed throws, I think it should at least be coupled with an “escape hatch” addition to Error:

protocol Error {
  var underlyingError: any Error? { get }
}

extension Error {
  var underlyingError: any Error? { nil }
}

Swift is an opinionated language, and is historically willing to make decisions on behalf of the programmer when learned experience points toward a clearly preferred solution. This is why untyped errors were originally chosen, and Swift should not allow programmers to engage in their abstract desire for “control” in contravention of this wisdom.

4 Likes

:+1:

Simplicity is the best. :slight_smile:

Thank you, @ksluder

Right, sorry, I was extrapolating to the broader pattern. I should have been clearer about that.

To restate (more clearly) what I mean, it's just that it's a good habit to get into to try-and-recover rather than precheck-and-try. It's not that either one is immune to security problems or confusing race conditions, but try-and-recover is more likely to be for any given specific case.

There's also other aspects to consider, like convenience, efficiency, and maintainability. Usually in precheck-and-try cases you have to do basically the same checks anyway when you actually try the operation - kind of analogous to double-checked locking in this regard - so duplicating that code is, well, redundant extra work. It also complicates the design since now you have to account for where your precheck succeeded but the real operation failed anyway, due to the inherent race conditions.

Call me biased, I suppose, but it is admittedly a pet peeve of mine when some API forces me to do non-trivial pre-validation because although it does that validation already, its failure behaviour is unusable (e.g. it just crashes, or it throws an exception that's too vague to be catchable, etc).

I think that's orthogonal. Being able to chain errors doesn't really solve any of the issues at hand, such as frozen error enums (unless you pre-allocate an "other" case, which of course just brings us back to square one).

I'm interested in chaining errors - its a fairly common capability of exceptions in other languages (e.g. Python), and I've found it useful in practice with very little downside - but I think it deserves its own proposal, because there are plenty of important design questions to be answered (e.g. how to avoid the mistakes commonly made in Python where people don't use the correct syntax and thus unwittingly replace the underlying errors rather than chaining).

While that principle is generally a good one, I don't think it applies here. I don't think we have any objective argument (let-alone proof) that typed errors are bad, nor even a majority opinion against them.

I think @Douglas_Gregor's proposal is really quite thorough and has made a good argument that typed errors can work well with Swift. Clearly there's disagreement over the intended breadth of applications, and expected usage conventions, and there's no guarantee we won't come to regret typed errors once we have real-world experience and see how people actually use them, but that's all true of any new feature or non-trivial change.

This particular proposal also has a 'recovery' path that's as close to ideal as you could hope for: if we ultimately decide typed errors are bad, we can collectively just not use them. Nothing in the proposal or their nature mandates their use - even Embedded Swift could use Result or similar instead, if it really had to. So the risk is relatively low, for a language change.

Yes, fair point. Assuming your top-level Error still facilitates keeping your sub-systems and application decoupled and only propagates what is actually needed for your application (i.e. a retry interval in your example) I agree this would be a reasonable use case.

What I would be concerned to see is a proliferation makeshift union types coupling an application to its dependencies or, worse, Error types generic over their subsystem Error types.

Maybe complimentary would be more accurate than orthoganol. Perhaps worth being spun-out into its own proposal.

For me, the problem it solves is: how to provide user/developer context for failures without using unwieldy union error types or a proliferation of otherwise disconnected unknown(any Error) cases that might be precipitated by the introduction of typed throws.

I do wonder if something like an attribute that can tell the compiler what errors a particular piece of API expects clients to handle could meet the needs of the “so you know you handled everything” crowd wanting typed throws.

Something like

@requiresHandlerFor(CatError)
func callCat() throws -> Cat { ... }
@requiresHandlerFor(KidError)
func callKids() throws -> [Kid] { ... }

do {
  try callCat()
  try callKids()
} catch let error as CatError {
 //handle the cat error
} //Compiler complains that KidError was expected to be handled and wasn’t

Would something like that meet the needs of the folks in that camp? Obviously this doesn’t help with the EmbeddedSwift motivation, but perhaps something like this (which I imagine would be relatively simply implemented, and could also probably be generated by an attached macro on the API types) might be a nice additional option that’s somewhere between the Wild West world of any Error and the strict lockdown world of typed throws.

2 Likes

Kind of like [the inverse of] @discardableResult?

Can you name any advantages this would have over typed throws? Because I’m only seeing disadvantages right now.