Rich Errors: Motivation and Rationale #447
Replies: 20 comments 70 replies
-
Question: is the synthetic Error supertype exposed in the API?Meaning that I can write code like the following: fun logError(error: Error /* Any error */) {
println(error.toString())
}
error object NotFound
fun main() {
logError(NotFound)
} If so, isn't it clearer if instead of a object NotFound: Error() What is missingWhat I really miss in the proposal is a better way to propagate the error to the parent caller: adding fn open_file() -> Result<(), Error> {
let file = File::open("hello.txt")?;
Ok(())
} From this point of view, even the third-party library Arrow provides monad comprehension with a less-verbose syntax. val dean = either<NotFound, Dean> {
val alice = student(Name("Alice")).bind()
val uca = university(alice.universityId).bind()
val james = dean(uca.deanName).bind()
james
} In my personal experience, expected runtime IO errors such as FileNotFound, NetworkOffline, etc. are very often just propagated to the caller rather than being handled directly, having some syntax to do that properly is quite important. Happy vs unhappy pathBy simply making an error a return value, we break all the libraries that make use of the "unhappy path". For example, a DB transaction fun <T> Database.transact(block: () -> T): T {
return try {
block()
} catch(t: Throwable) {
rollback()
throw t
}
}
fun doSomething(): String | MyError
fun main() {
// doesn't rollback the transaction on error
val result = db.transact {
doSomething()
}
} or even kotlinx.coroutines fun main() = coroutineScope {
val deferred = async { readFile() }
// Most likely I want the following to be cancelled if readFile returns an Error
launch {
delay(1000)
println("I was not cancelled!")
}
} potentially becoming a major pain for the migration-to-errors period. Libraries that want to handle error types will have to start adding the additional It's also unclear how an opaque // before
fun main() = coroutineScope {
val deferred1: Deferred<Int?> = async { list.firstOrNull() } // Doesn't cancel the scope on missing element
val deferred2: Deferred<ByteArray> = async { readFile() } // Cancels the scope (throws) on missing file
}
// after
fun main = coroutineScope {
val deferred1: Deferred<Int | NoSuchElement> = async { list.first() } // Should it cancel the scope?
val deferred2: Deferred<ByteArray | FileNotFound> = async { readFile() } // Should it cancel the scope?
} So I'm not sure that having a flat hierarchy that puts together basic cases like From this point of view, the Arrow library provides a more pragmatic solution since the monad comprehension logic is based on fun main() = either {
// Creates the binding scope
coroutineScope {
/* ... */
// propagates the error to the binding scope through a throwable, cancelling the coroutine scope as expected
val deferred2: Deferred<ByteArray> = async { readFile().bind() }
}
} I would love to see Kotlin having a solution that goes towards that syntax, even though it's more challenging on the implementation side (e.g. ensure throwable propagation, avoid escaping functions, etc.). suspend fun main(): ByteArray | NotFoundError = coroutineScope {
// if any file reading fails, it cancels the scope and returns the error
val file1 = async { readFile("1.txt")? }
val file2 = async { readFile("2.txt")? }
file1.await() + file2.await()
} With the current proposal suspend fun main(): ByteArray | NotFoundError = coroutineScope {
// How to implement the example above correctly? Impossible
} |
Beta Was this translation helpful? Give feedback.
-
Might that be relaxed in the future? Especially with generic parameters. I think Ross Tate's presentation (on type outference) implied that generics can be supported while still staying polynomial |
Beta Was this translation helpful? Give feedback.
-
Typo in this
Also a clarification that Err2 is an |
Beta Was this translation helpful? Give feedback.
-
I'd be in support of Restricted Types for this. They don't even need new contract support. |
Beta Was this translation helpful? Give feedback.
-
I'm very against this! If we want errors to be true values, then existing functions that take generic type parameters should support errors too. I think adding a special fun <T> requireNotNull(t: T): T & Any Would still be perfectly functional with errors, but now it isn't. fun <T: Any> requireNotNull(t: T?): T Similarly, I think the situations where a generic function must take a non-Error value are very rare, and it's okay to make them need special syntax or an extra type bound or something for that. Consider also all datatypes. Do you see what I mean? Errors are values, so it's important to allow them everywhere by default, and only exclude them explicitly when it's necessary. Maybe having negative type bounds can be useful too here (I believe this was discussed briefly in the YouTrack issue) because then we can do this: error object NotFound
inline fun <T> Sequence<T>.last(predicate: (T) -> Boolean): T where NotFound ~: T {
var last: T | NotFound = NotFound
for (element in this) {
if (predicate(element)) {
last = element
}
}
if (last == NotFound) throw NoSuchElementException()
return last // smart-cast to T
} so that this can still work with Errors other than |
Beta Was this translation helpful? Give feedback.
-
In general i like the idea, i have 2 questions regarding ergonomics Firstly, assuming a function like fun fetchBalance(): Double | Error how our handling is supposed to look like? Something like below? when (val balance = fetchBalance()) {
is Double -> ...
is Error -> ...
} Also, how will we be able to deal with multiple errors? Will we need to nest them? fun fetchBalance(): Balance | BalanceError
fun fetchAccount(): Account | AccountError
fun deposit() {
when (val account = fetchAccount()) {
is Account -> {
when (val account = fetchBalance()) {
is Balance -> ...
is BalanceError -> ...
}
}
is AccountError -> ...
}
} With arrow for example, we can use bind, from the docs fun foo(n: Int): Either<Error, String> = either {
val s = f(n).bind()
val t = g(s).bind()
t.summarize()
} |
Beta Was this translation helpful? Give feedback.
-
I think I would also think that the risk of accidentally swallowing the error in I'm curious, did you have some evidence that this isn't the case that pushed you towards not including |
Beta Was this translation helpful? Give feedback.
-
Are there any plans to make the mentioned |
Beta Was this translation helpful? Give feedback.
-
Much of this proposal makes use of the difference between "recoverable" and "unrecoverable" errors. The intent is that "recoverable" errors can use error types and be part of the function signature. However, I think there's a problem with this: what is recoverable and what isn't is not up to the called function, but rather the caller. How can a function definition know what should be a recoverable error and what shouldn't without knowing how it's called? This is especially a problem for stdlib and library functions. This is touched on in the "Errors as values section"
But then isn't talked about again in the proposed solution. Examples are not that hard to come by. The "username is empty" error may be unrecoverable to the user-add service, but recoverable in the UI where we show some error text. Or a "wrong format exception" may be unrecoverable for that format, but recoverable for a multi-format process, except if it matches no known formats. The broader point, I think, is that the recoverability of an error is context-dependent and may vary back and forth throughout the call tree. Library functions have a relatively easy solution: treat everything as potentially recoverable (i.e. an error type) and let the caller decide that it can't recover from some errors. It could get very verbose very fast, though, and doesn't handle the case where some errors are recoverable very well. I think there's a general need for more ability to easily transition errors between "recoverable" and "unrecoverable" (and back) throughout the call tree. Making some errors unrecoverable can be done with I think functions to treat some errors as unrecoverable (i.e. throw them) would help with this. For example, a Making some unrecoverable errors (or exceptions) aka "widening" can be done using
But this is awkward and verbose, especially for multiple potentially erroneous calls, and it's easy to accidentally not handle an error that is in your return signature. I think it would be helpful to have a |
Beta Was this translation helpful? Give feedback.
-
This I really don't like. From your own example:
What if I want to throw if there's an error, but otherwise keep my Wouldn't it be better to have a simply |
Beta Was this translation helpful? Give feedback.
-
Is Eg we have If |
Beta Was this translation helpful? Give feedback.
-
Could you explain why the arguments against repurposing I really dislike repurposing In the same vein, we could have I have a gut feeling that mixing null-safe and error-safe is a bad idea, especially given the current state of the ecosystem. If a function returns For instance, I would like to be able to process nullable elements just like other elements, while still playing with the error dimension: val list: List<Int?> = listOf(null, 0, 1)
list.firstOrError()!.let(::println) !: println("no element") |
Beta Was this translation helpful? Give feedback.
-
I think it means we can't write
But what about
|
Beta Was this translation helpful? Give feedback.
-
I'd like to get a response to the issues I raised in this YouTrack comment and which have also been raised above by @rnett - I'll summarize them quickly here.
If I was asked to choose between the precondition improvements or the so-called "rich" errors (which have fewer features than exceptions), I'd go for the precondition improvements. My codebases are always full of preconditions but I never felt the need to introduce custom |
Beta Was this translation helpful? Give feedback.
-
What are the valid property types for error classes? I'd like to be able to do something like: error class FieldError(val fieldName: String, val error: Error) |
Beta Was this translation helpful? Give feedback.
-
How are these unions going to be expressed in Java bytecode? Is interop a priority? |
Beta Was this translation helpful? Give feedback.
-
I'm in favor of forbidding nullable error types at all for now. From what is said in the KEEP, this would be forbidden: error object Foo
typealias FooOrNull = Foo?
fun foo(): String | FooOrNull // ! Compile-time error, error object cannot be nullable If that's the case, I think it is less confusing if |
Beta Was this translation helpful? Give feedback.
-
Overall, I really like this KEEP, and I think it will make many things better. I particularly like the section "Local tags and in-place errors". I didn't realize this design could be used in such a way, and I think this will help a lot in many situations. |
Beta Was this translation helpful? Give feedback.
-
I would love a section with more precision on how users should use errors and exceptions together. For example, it's common that something that is locally an error ("the thing you requested is not found") cannot be recovered locally and actually should become an exception to be handled by the framework elsewhere. The function itself should still use errors, because in some other cases it may be recoverable, but in some cases callers should make the decision that it's actually not recoverable there. Do we except users to just use error class FailedA
try {
…
} catch (e: FailedA) {
x()
} which would desugar to: try {
…
} catch (e: KotlinErrorException) {
if (e is FailedA) x()
else throw e
} Or alternatively, some kind of "catch guards": try {
…
} catch (e: KotlinErrorException if e is FailedA) {
…
} Otherwise, I fear that this will become very verbose, and thus people will be lazy and just write |
Beta Was this translation helpful? Give feedback.
-
Thank you for publishing the proposal 🙏 Here is some feedback even though it's been mostly covered above:
There is a typo in the link https://github.com/Kotlin/KEEP/blob/main/proposals/proposals/KEEP-0412-unused-return-value-checker.md. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
This is a discussion of motivation and rationale behind rich errors. The current full text of the proposal can be found here:
Please note that a detailed design document on rich errors will be submitted later, once we collect more feedback and figure out the missing pieces.
Beta Was this translation helpful? Give feedback.
All reactions