Nir
10/26/2020, 1:49 PMinterface Result<out T> : Serializable
inline class Outcome<out T, out E: Throwable> : Result<T>
If you do something like this, then you can use Outcome
as local error handling wherever you want to keep the type. And, if you need to collect errors from multiple sources, you just let Outcome up-convert to a Result.
Things like runCatching
could construct an Outcome<T, Throwable>
(i.e. the most general case of Outcome) which could then be allowed to up-convert to Result instantly (leaving their API identical to now)Marc Knaup
10/26/2020, 2:22 PMinterface
would cause a lot of unnecessary boxing of the inline class
instance.gildor
10/26/2020, 2:44 PMJavier
10/26/2020, 3:10 PMNir
10/26/2020, 3:55 PMMarc Knaup
10/26/2020, 3:57 PMResult
is used for every single coroutine continuation. So it’s used pretty extensively and in a very performance-critical situation.
Result
isn’t made available as a general purpose class. Your suggestion should likely be its own type, at least until Kotlin has more capabilities to make a single class a good fit for all use cases.Nir
10/26/2020, 4:06 PMNir
10/26/2020, 4:07 PMNir
10/26/2020, 4:08 PMNir
10/26/2020, 4:08 PMMarc Knaup
10/26/2020, 4:10 PMResult
as a return type at the moment. The uses are mostly limited to coroutines and runCatching
at the moment afaik.
I agree that error handling in Kotlin is not that nice. I like Swift’s approach.Nir
10/26/2020, 4:10 PMtoIntOrNull
method isn't that great.Marc Knaup
10/26/2020, 4:11 PMOrNull
are quite annoying. Seems like a workaround.Nir
10/26/2020, 4:11 PMMarc Knaup
10/26/2020, 4:11 PMNir
10/26/2020, 4:12 PMtoInt
return Outcome<Int, ParseError>
(or whatever), and then you either call getOrThrow
, or getOrNull
, etcNir
10/26/2020, 4:13 PMelizarov
10/26/2020, 4:13 PMtoInt
operation very expensive. Hence we have a compromise with a toInt
and toIntOrNull
combo that this both efficient and pragmatic.Marc Knaup
10/26/2020, 4:13 PMInt | NotApplicable | List<Error>
etc.Nir
10/26/2020, 4:15 PMMarc Knaup
10/26/2020, 4:15 PMNir
10/26/2020, 4:15 PMresult.getOrElse { return it }
Nir
10/26/2020, 4:16 PMelizarov
10/26/2020, 4:16 PMtoInt
would be defined to return some kind of Outcome<Int, ParseError>
then it would be very cumbersome to use in a typical situation when you know that input must be an int and you just want your program to crash on invalid input. This is like happens in 99% of code where parse anything, etc. There is really no much sense to be forced to write the code to say "I just want to crash here" every time you attempt to convert a string to an int.Nir
10/26/2020, 4:17 PMNir
10/26/2020, 4:17 PMNir
10/26/2020, 4:17 PMMarc Knaup
10/26/2020, 4:18 PMtry! something()
. The !
makes it explicit that you expect it to not fail.
Or try?
to return null
on failure.
It’s just a little annoying to use because you have to put it in braces for longer chains.elizarov
10/26/2020, 4:18 PMelizarov
10/26/2020, 4:20 PMsomething()
for a typicall(sic!) use-case of try! something()
and write somethingOrNull()
for a rare use-case when you need try? something()
Nir
10/26/2020, 4:20 PMNir
10/26/2020, 4:21 PMwhen
and pattern matchingMarc Knaup
10/26/2020, 4:21 PMelizarov
10/26/2020, 4:21 PMawait
on each line when all your code is doing asynchronous stuff.Nir
10/26/2020, 4:21 PMsomethingOrNull
. IMHO somethingOrNull
just isn't that useful.Nir
10/26/2020, 4:22 PMsomethingOrNull
vs trySomething
that returns something similar to a Result<Something> ?elizarov
10/26/2020, 4:23 PMsomething()
and somethingOrNull
pairs yourself. Not a big deal for apps (you usually know which one you need), but could be a bit of a pain for libraries.Marc Knaup
10/26/2020, 4:23 PMawait
has its own language feature: suspending functions.
There’s no such thing for error handling.Nir
10/26/2020, 4:24 PMNir
10/26/2020, 4:24 PMwhen
), with actually error informaiton (not optional), and also ergonomic to convert into an exception for non-immediate error handlingMarc Knaup
10/26/2020, 4:25 PMNir
10/26/2020, 4:25 PMNir
10/26/2020, 4:25 PM!!
give you the result or throwelizarov
10/26/2020, 4:25 PMNir
10/26/2020, 4:26 PMNir
10/26/2020, 4:26 PMelizarov
10/26/2020, 4:27 PMNir
10/26/2020, 4:27 PMelizarov
10/26/2020, 4:27 PMMarc Knaup
10/26/2020, 4:27 PMNir
10/26/2020, 4:27 PMMarc Knaup
10/26/2020, 4:28 PMNir
10/26/2020, 4:28 PMNir
10/26/2020, 4:28 PMNir
10/26/2020, 4:28 PMelizarov
10/26/2020, 4:29 PMfoo() -- return domain-specific result
foo().orThrow() -- to throw exception on error
foo().orElse { return it } -- to return error (like ? in Rust)
when(foo()) { ... } -- to decomose it
// etc
Nir
10/26/2020, 4:29 PMelizarov
10/26/2020, 4:29 PMMarc Knaup
10/26/2020, 4:30 PMelizarov
10/26/2020, 4:30 PMNir
10/26/2020, 4:30 PMelizarov
10/26/2020, 4:31 PMMarc Knaup
10/26/2020, 4:31 PMMarc Knaup
10/26/2020, 4:31 PMMarc Knaup
10/26/2020, 4:31 PMNir
10/26/2020, 4:31 PMinterface OutcomeBase<R>
and inline class Outcome<R, E> : OutcomeBase
, then some classes would use Outcome directly (making life easier), some would not but might use the OutcomeBase interface, some would use neither but provide getters that returned outcomes... etcMarc Knaup
10/26/2020, 4:31 PMNir
10/26/2020, 4:32 PMMarc Knaup
10/26/2020, 4:32 PMtry
are all closer than anything Kotlin has to offer.elizarov
10/26/2020, 4:33 PMNir
10/26/2020, 4:33 PMtry
, not necessarily that I think it's the perfect approach, but the basic assertion that error handling is something work expending language design real estate onNir
10/26/2020, 4:34 PMMarc Knaup
10/26/2020, 4:35 PMelizarov
10/26/2020, 4:35 PMNir
10/26/2020, 4:35 PMNir
10/26/2020, 4:36 PMelizarov
10/26/2020, 4:36 PMNir
10/26/2020, 4:38 PMNir
10/26/2020, 4:39 PMResult
, or in some cases building on top of ResultNir
10/26/2020, 4:39 PMelizarov
10/26/2020, 4:39 PMShouldn’t either Kotlin as a language or the stdlib help facilitate a “common” approach for something that’s as fundamental as raising and handling errors?
What programs are there that won’t need that?But Kotlin is facilitating a common approach: • If that's a programmer error, throw an excaption. It just like "panic". You program shall crash/log/report it/wahtever global action you have for cases like that • If that's an input or external error then simply return as a result of your function and process it at this function's call site. The whole Kotln stdlib is centered around this simple approach.
Nir
10/26/2020, 4:40 PMMarc Knaup
10/26/2020, 4:40 PMNir
10/26/2020, 4:41 PMResult
type. It doesn't really make sense to provide nothing, and tell people to define their own type for your second bulleted approachMarc Knaup
10/26/2020, 4:41 PMNir
10/26/2020, 4:41 PMsealed class Whatever { class Success, class Error } ....
Marc Knaup
10/26/2020, 4:41 PMelizarov
10/26/2020, 4:42 PMWell, for the second approach though, there is no help with respect to defining the type, even though there's clearly a lot of room for widely reusable code thereBut for majority of cases you don't need any additional details on the error and the nullable type works well. The rest, when you need additional error details, are very domain specific. What's the problem with a domain-specific sealed class for that?
Nir
10/26/2020, 4:42 PMResult
in your standard library vs advice to write it from scratch, IMHONir
10/26/2020, 4:43 PMNir
10/26/2020, 4:43 PMResult
APIs that are slightlyd ifferent, and a bigger headache when errors need to be propagated upelizarov
10/26/2020, 4:43 PMNir
10/26/2020, 4:44 PMNir
10/26/2020, 4:44 PMNir
10/26/2020, 4:44 PMOutcome<Result, Error>
is a generic classNir
10/26/2020, 4:45 PMError
can be vastly different from library to library, it can have literally any API they wantelizarov
10/26/2020, 4:45 PMNir
10/26/2020, 4:45 PMNir
10/26/2020, 4:46 PMelizarov
10/26/2020, 4:46 PMNir
10/26/2020, 4:47 PMOutcome<result, Error>
which is generic wouldn't work across many domains...Nir
10/26/2020, 4:47 PMResult
are things that are fairly usefulNir
10/26/2020, 4:49 PMMarc Knaup
10/26/2020, 4:49 PMa(b(c()))
would become something like
c().mapValue { b(it) }.mapValue { a(it) }
throughout the entire codebaseMarc Knaup
10/26/2020, 4:50 PMelizarov
10/26/2020, 4:50 PMI mean I think it helps to look at real world examples.💯
Marc Knaup
10/26/2020, 4:55 PMnull
, catch
and a few Result
.
I would’ve used my GraphQL library as an example but I’ve already refactored the result approach out as much as possible.
Right now the situation is mostly random exceptions throughout the app that get fixed on the fly as they occur.
Everyone’s migrating from one world (checked exceptions) to another (all unchecked) to another (use domain specific types, just not documented afaik).Marc Knaup
10/26/2020, 4:56 PMOrNull
.
Using a sealed class causes elizarov
10/26/2020, 4:58 PMNir
10/26/2020, 4:58 PMResult
, is that people feel it's not encouraged and they just use exceptionsMarc Knaup
10/26/2020, 4:58 PMNir
10/26/2020, 4:59 PMMarc Knaup
10/26/2020, 4:59 PMrunCatching
sometimes 😛elizarov
10/26/2020, 5:00 PMMarc Knaup
10/26/2020, 5:03 PMMarc Knaup
10/26/2020, 5:04 PMNir
10/26/2020, 5:04 PMNir
10/26/2020, 5:04 PMMarc Knaup
10/26/2020, 5:04 PMMarc Knaup
10/26/2020, 5:06 PMMarc Knaup
10/26/2020, 5:07 PMOrNull
vs throw
vs Result
vs sealed class
Marc Knaup
10/26/2020, 5:08 PMraulraja
10/26/2020, 5:39 PMraulraja
10/26/2020, 5:42 PMUnion<A, B>
and would be much better to have as infix types with |
A | B
Fleshgrinder
10/26/2020, 6:00 PM?
)
- ...
A Result type comes with its own issues, and it's actually identical to checked exceptions. So is the union type idea. The only difference is that any throw
directly starts unwinding the stack, a return doesn't. Yes, there's a difference in syntax (try/catch) but this is artifical and doesn't need to be there.
Also, who said that exceptions are limited to inheritance. One could also define them as an enum or sealed class, or whatever.
I think it's possible to find a solution that makes everyone happy (ergonomic) and allows Java interop as well as great performance but it would need to be exception based to achieve that.Nir
10/26/2020, 6:05 PManyhow
is more or less a more sophisticated version of what I suggest here with Outcome<T, E>
inheriting from OutcomeBase<T>
or whateverNir
10/26/2020, 6:05 PMNir
10/26/2020, 6:06 PManyhow
exists.Nir
10/26/2020, 6:07 PMinline class
mitigates type erasure like it can for functions. If it could, that would be helpful as it would allow downcasting out of the box (as anyhow
does)Fleshgrinder
10/26/2020, 7:20 PMthrows
as part of signatures but only allow one throwable in there. Now we have the equivalent of what's proposed here in regards to making errors explicit. In code fun f(...): Result<T, E> {}
becomes fun f(...): T throws E {}
Now on the calling side we can have f(...)!
to simply let the error go up, f(...)!?
to ignore and turning it into null
, or f()
in which case you either get T
or E
(back to union) and need to check on your own.
All of this is transformed to standard try/catch in the byte code. So from a Java perspective nothing actually changed.
This is a very rough idea from the top of my head, so be gentle. 😉Nir
10/26/2020, 7:51 PMtry
in swift or the proposed static exceptions in C++Fleshgrinder
10/26/2020, 8:52 PMx.a()!.b()!?.c()?.d()
Marc Knaup
10/26/2020, 8:55 PM!
and ?
are for nullability, not for errors. That’s would be ambiguous and confusing.
What would happen if you mix a nullable type with an error-returning type?
Anyway, the status quo of error handling is quite chaotic. I suggest to properly organize that first an only then improve upon it. Otherwise we have an unstable foundation for further improvements.Fleshgrinder
10/26/2020, 9:07 PM!!
and ?
but not !
nor !?
and the symbols as proposed would be very close in semantics:
- !
throw if error
- !!
throw if null
- ?
nullsafe call
- !?
null if error and nullsafe call
I didn't come up with this as an improvement but rather as an idea to properly organize things, but while taking all requirements (except my own "stack unwinding" issue for which I don't know any solution without additional object creation or giving up Java interop) into account.Fleshgrinder
10/26/2020, 9:12 PM?!
is more logical than !!
for throw if null. I didn't propose that because it would require a depreciation of a well established operator and it could probably only be removed in Kotlin 2 but I'm sure not many would like this for such a simple thing.
The biggest problem really is the unwinding, using exceptions like this means that you use them for control flow too and this is wasteful. In Rust a lot of thought and effort goes into making their Result as lightweight as possible because of this.Marc Knaup
10/26/2020, 9:14 PMFleshgrinder
10/26/2020, 9:18 PM?!
as well for nullsafe and throw on error.
`T`otally, looks like Perl. 😆 But there's no other way to overcome the cumbersome issue mentioned by you and @elizarov. Either you have short easy syntax or, well, nothing, like we have it now.Nir
10/26/2020, 9:21 PMNir
10/26/2020, 9:21 PMval result = withError { a(b(c())) }
Nir
10/26/2020, 9:22 PMrunCatching
+ exceptions give youFleshgrinder
10/26/2020, 9:22 PMNir
10/26/2020, 9:23 PMNir
10/26/2020, 9:24 PMoutcome<T, E>
, that way if you decide to handle errors specifically for a function, you get the benefits of type safetyFleshgrinder
10/26/2020, 9:26 PMT
or E
and you need to check first. You just don't have an explicit wrapping type to avoid the boxing and keep normal Java signatures.Nir
10/26/2020, 9:26 PMNir
10/26/2020, 9:26 PMoutcome<T>
and outcome<T, E>
Nir
10/26/2020, 9:28 PMFleshgrinder
10/26/2020, 9:28 PMT | E
not be type safe if you have the required compiler machinery? Have a look at Ceylon, they really mastered this.Nir
10/26/2020, 9:28 PMNir
10/26/2020, 9:29 PMthrows
, then you aren't specifying E individually thoughNir
10/26/2020, 9:29 PMNir
10/26/2020, 9:30 PMFleshgrinder
10/26/2020, 9:31 PMResult<T, E>
and T | E
are equivalent...Nir
10/26/2020, 9:31 PMfun f(...): T throws E {}
Fleshgrinder
10/26/2020, 9:31 PMNir
10/26/2020, 9:31 PMthrows
is already used often in languages, without the E
Nir
10/26/2020, 9:31 PMFleshgrinder
10/26/2020, 9:31 PMNir
10/26/2020, 9:32 PMFleshgrinder
10/26/2020, 9:34 PMNir
10/26/2020, 9:34 PMthrows
, you're forced to handle the errorsNir
10/26/2020, 9:34 PMNir
10/26/2020, 9:34 PMNir
10/26/2020, 9:34 PMthrows
, it auto unwraps everything. It's basically like my scope idea, but for the entire function scopeNir
10/26/2020, 9:35 PMFleshgrinder
10/26/2020, 9:35 PMFleshgrinder
10/26/2020, 9:36 PMNir
10/26/2020, 9:36 PMNir
10/26/2020, 9:39 PMNir
10/26/2020, 9:39 PMNir
10/26/2020, 9:40 PMthrows
suggestion, is special syntax not to throw the exception, but rather, special syntax to ask to handle the E explicitly.Nir
10/26/2020, 9:40 PMNir
10/26/2020, 9:41 PMwhen
Nir
10/26/2020, 9:41 PMT throws E
, as you suggest. Then a(b(c()))
"just works" and if there's an error it throws, just like normal exceptions throw nowNir
10/26/2020, 9:42 PMwhen (val x = c()) ...
then there's no exception, and we automatically start pattern matching on T
and E
Nir
10/26/2020, 9:42 PMNir
10/26/2020, 9:43 PMFleshgrinder
10/26/2020, 9:43 PMilya.gorbunov
10/26/2020, 10:15 PMbut if you haveIs it possible to distinguish this way whenthen there's no exception, and we automatically start pattern matching on T and Ewhen (val x = c()) ...
E
is thrown from when it's returned?Nir
10/26/2020, 10:25 PME
would be thrownNir
10/26/2020, 10:25 PMfun c(): T throws E
or something similarNir
10/26/2020, 10:26 PMC
you would either have to return a T
, or throw an E
Nir
10/26/2020, 10:27 PMNir
10/26/2020, 10:28 PMNir
10/26/2020, 10:28 PM!?
or something like thatilya.gorbunov
10/26/2020, 11:17 PMeither have to return a T , or throw an EBut what if
T
is the same as E
or its supertype, e.g. Any
?
For example, if you have a List<T>
and store exceptions in it, would it be possible to distinguish an exception thrown by hypothetical first(): T throws E
from an exception returned by that first()
?Nir
10/26/2020, 11:48 PMT
or Failure<E>
, or something like that, where Failure
is some type not accessible to users i.e. it's just there to ensure that the error type is always distinct from the resultMarc Knaup
10/27/2020, 2:20 AMResult
type in a scenario where you want to return Any
or Any?
then it's all to easy to accidentally create a Result<Result<Any>>
. Here also the distinction between what is a the actual return value and what is a success/error wrapper becomes difficult and mistakes happen very easily.Nir
10/27/2020, 3:53 AMNir
10/27/2020, 3:54 AMNir
10/27/2020, 4:16 AMfun String.toInt() : Int throws FormatError, CapacityError { ... }
Despite appearances, normal usage stays the same: "hello".toInt()
compiles and throws an exception.
However, if users want to they can opt in to static checking. They do this with some sigil. Let's say !?
for now.
when !?
is used. you get the behavior of checked exceptions. Meaning: if it's outside a try block, it propagates the exception, and the current function needs to have compatible throws
. If inside a try block, it only tries to propagate any exceptions not handled.
fun foo() : Int { return "hello".toInt()!? } // Doesn't compile
fun foo() : Int throws FormatError, CapacityError { return "hello".toInt()!? } // compiles, propagates errors
fun foo() : Int { return try { "hello".toInt()!? } catch (FormatError) { 0 } catch (CapacityError) { 100 } } // compiles
fun foo() : Int throws CapacityError { return try { "hello".toInt()!? } catch (FormatError) { 0 } } // compiles
Nir
10/27/2020, 4:18 AMNir
10/27/2020, 4:20 AMwhen
instead of try-catch, or even something else, but the basic idea remains the same. library APIs can optionally annotate statically the errors. If they do, then users can opt-in to static handling.Fleshgrinder
10/27/2020, 7:03 AMwhen
would not work because your function throws, you have to convert it. The idea is that the byte code in the background still is normal try/catch.
Without special handling the solution already exists in std (kind of):
when (val x = runCatching { f() }) {
Success => a(x)
Failure => b(x)
}
I personally don't like this syntax because it feels unnatural, having special handling in form of a normal function could work here too (the opposite of Rust's unwrap
):
when (val x = f().wrap()) {
Success => a(x)
Failure => b(x)
}
Here wrap
would need to be understood by the compiler so that it can turn it into try/catch.
try {
a(f())
} catch (E e) {
b(e)
}
Or we directly special case any such function if it is used in when
and simply assume that the user wants to match on T | E
and not on T
. Although, I think that an explicit wrap
is more useful because you can turn the result of any such function into a Result<T, E>
explicitly everywhere, and it does not only work in when
.
So, yes, we can distinguish them.Fleshgrinder
10/27/2020, 8:02 AMtoInt()
and toIntOrNull()
performance discussion (which could become toInt()!
and toInt()!?
as the idea/proposal currently stands): https://shipilev.net/blog/2014/exceptional-performance/
The biggest advantage I see here is that we would not need to write two functions for everything anymore that work slightly different just to make the unhappy path faster. Even something simple (on the surface) as parsing a string to an integer can fail at multiple places and it would be nice to tell the user what went wrong. We have two options here, write the toIntOrNull()
first that always simply returns null
and wrap this in toInt() = toIntOrNull() ?: throw
where all we know is that parsing failed, that's it. Or we go the other way and implement toInt()
and define toIntOrNull() = try { toInt() } catch (E) { null }
which would probably be optimized away anyways and give us the same result but we could tell the user exactly what went wrong where. Or we write two functions, which is what we currently have in std, that might suffer from incompatibilities and diverging changes.
I really don't think that optimizing for the unhappy path is so important, especially considering the above benchmarks for exceptions.