Rich Errors: Motivation and Rationale #447
Replies: 22 comments 78 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.
-
First, I'd like to echo the comments people have made about the recoverability, or not, of a given error is extremely context dependent. Even within the same block of code an error might be recoverable sometimes and sometimes not. E.g., a loop that repeats some operation that might fail N times, until hitting some maximum number of retries. While N is less than the maximum number of retries the error is recoverable. After, it is not. Also, the comments about the risks of repurposing Second, I think it would be extremely helpful if the proposers could present multiple examples of code written using this proposal, so we can compare it with equivalent code written using kotlin-result, or Arrow, or other common libraries. And/or take real world example of code written using those libraries and demonstrate how this proposal would improve the code. As I show below, I'm not convinced the proposal provides sufficient new functionality to justify inventing new types and syntax in the core language.
The rest of this is long, so I've hidden it behind a I have a moderate sized Kotlin project (Android) that needs to handle errors in many situations -- network errors, database errors, data format errors (e.g., servers returning invalid JSON), data-out-of-sync errors, and more. Sometimes -- and it is entirely context dependent, and may change from time to time at the same callsite -- those errors are recoverable / retryable. Other times they are not, and the error must be surfaced to the user. A few things fall out of this for me.
I use https://github.com/michaelbull/kotlin-result for this. Some real-world example of kotlin-result in my app:
In the proposal there is https://github.com/Kotlin/KEEP/blob/main/proposals/KEEP-0441-rich-errors-motivation.md#result-either-from-3rd-party-libraries, and in that section you use the following code as an example: fun getUserResult(): Result<User> {
val user = fetchUserResult().getOrElse { return Result.failure(it) }
val parsedUser = user.parseUserResult().getOrElse { return Result.failure(it) }
return Result.success(parsedUser)
}
fun usageResult() {
getUserResult()
.onSuccess { user -> println(user.name) }
.onFailure { error ->
when (error) {
is NetworkException -> println("Failed to fetch user")
is ParsingException -> println("Failed to parse user")
else -> println("Unhandled error")
}
}
} I'm not sure that's idiomatic for kotlin-result. As such, I don't think it's a reasonable comparison with the proposal. With kotlin-result that might look like this: interface AppError {
// All app errors support a function to format the error for display.
fun fmt(): String
}
sealed class GetParsedUserError() : AppError
// These are simple wrappers around a Throwable, but they could be more
// complex (e.g., recording HTTP errors, hostname, etc, for later use).
data class FetchUserError(val t: Throwable) : GetParsedUserError {
fun fmt() = t.message
}
data class ParseUserError(val t: Throwable) : GetParsedUserError {
fun fmt() = t.message
}
fun fetchUser() = runSuspendCatching { getFetchedUserOrThrow() }.mapError { FetchUserError(it) }
fun parseUser(fetchedUser: FetchedUser) =
runSuspendCatching { parseUserJsonOrThrow(fetchedUser) }.mapError { ParseUserError(it) }
fun getParsedUser() = fetchUser().map { parseUser(it) }
// Equivalent to example's `usageResult`.
fun usage() {
getParsedUser()
.onSuccess { user -> println(user.name) }
.onFailure { error ->
when (error) {
is FetchUserError -> println("Could not fetch user: ${error.fmt()}")
is ParseUserError -> println("Could not parse user: ${error.fmt()}")
}
}
}
// More idiomatic version of `usageResult`.
fun idiomaticUsage() {
val user = getParsedUser().getOrElse { err ->
println(err.fmt())
return
}
println(user.name)
}
At first blush this is more code, but it's more code that does more than in the example presented in the KEEP. It defines the set of errors that can be returned by The example in the KEEP also isn't very idiomatic (in my experience). You don't have the My example also shows how an error can be reponsible for providing a formatted value to display to the user. In the example it's crude, but the If I've understood the KEEP correctly, the example I gave using kotlin-result would look something like this with the new Error type. // These are simple wrappers around a Throwable, but they could be more
// complex (e.g., recording HTTP errors, hostname, etc, for later use).
error FetchUserError(val t: Throwable)
error ParseUserError(val t: Throwable)
typealias GetParsedUserError = FetchUserError | ParseUserError
fun fetchUser(): FetchedUser | FetchUserError {
return try {
getFetchedUserOrThrow()
} catch (t: Throwable) {
FetchUserError(t)
}
}
fun parseUser(fetchedUser: FetchedUser): ParsedUser | ParseUserError {
return try {
parseUserJsonOrThrow()
} catch (t: Throwable) {
ParseUserError(t)
}
}
fun getParsedUser(): ParsedUser | GetParsedUserError {
return fetchUser()?.let { parseUser(it) }
}
fun usage() {
val userOrError = getParsedUser()
userOrError.ifError { error ->
when (error) {
is FetchUserError -> println("Could not fetch user: ...")
is ParseUserError -> println("Could not parse user: ...")
}
return
}
// Does this work? Can the compiler infer userOrError is not an Error at
// this point?
println(userOrError.name)
} To my eyes that is:
|
Beta Was this translation helpful? Give feedback.
-
Will |
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