Kotlin's rich errors: Native, typed errors without exceptions
Posted by todsacerdoti 6 days ago
Comments
Comment by armchairhacker 1 day ago
Which leads me to: why is Kotlin implementing this in a non-JVM compatible way, instead of introducing checked exceptions with better language support? All the problems stated above can be avoided while keeping the core idea of checked exceptions, which seems to be the same as this proposal.
From the GitHub discussion, I see this comment (https://github.com/Kotlin/KEEP/discussions/447#discussioncom...):
> The difference between checked exceptions from java and error unions in this proposal is how they are treated. Checked exceptions are exceptions and always interrupt the flow of execution. On the other hand, errors in this proposal are values and can be passed around as values or intentionally ignored or even aggregated enabling the ability to use them in async and awaitAll etc.
But is this a real difference or something that can be emulated mostly syntactically and no deeper than Kotlin's other features (e.g. nullables, getters and setters)? Checked exceptions are also values, and errors can be caught (then ignored or aggregated) but usually interrupt the flow of execution and get propagated like exceptions.
Comment by loglog 1 day ago
The one feature that the proposed Kotlin error types share with Java checked exceptions is that they can be collected in unions. However, the union feature for checked exceptions is pretty much useless without the ability to define higher order functions that are generic over such unions, which is why checked exceptions fell out of favor with the spread of functional APIs in Java 8.
Comment by ajrouvoet 23 hours ago
Comment by sirwhinesalot 1 day ago
Checked exceptions only make sense for errors that are relatively common (i.e., they aren't really exceptional), which calls for a different implementation entirely where both the happy path and the error path have around the same cost.
This is what modern languages like Rust and Go do as well (and I think Swift as well though don't quote me on that) where only actually exceptional situations (like accessing an array out of bounds) trigger stack unwinding. Rust and Go call these panics but they are implemented like exceptions.
Other errors are just values. They have no special treatment besides syntax sugar. They are a return value like any other and have the same cost. As they aren't exceptional (you need to check them for a reason), it makes no sense to use the exception handling mechanism for them which has massively skewed costs.
Comment by vips7L 4 hours ago
Comment by elcritch 7 hours ago
Once they’re the standard error method then case every function has to intertwine branching for errors paths vs normal paths. Often the compiler has to generate unique code or functions to handle each instance of the Result type. Both add to code size and branching size, etc.
Comment by HendrikHensen 23 hours ago
I don't know about Rust, but a very important difference between Java exceptions and Go panics, is that a Go panic kills the entirely process (unless recovered), whereas a Java exception only terminates the thread (unless caught).
It's a little off-topic, but I wanted to clarify that for passer-bys who might not know.
Comment by tester756 23 hours ago
Depends on the software, huh.
Comment by guelo 10 hours ago
Comment by wiseowise 8 hours ago
It’s because JB intends to make money on their investment, unlike the other two. This is the reason why Kotlin ecosystem is a half baked mess – JB tries to artificially replicate all successful ecosystems without having a community.
Comment by mike_hearn 4 hours ago
Seems to me like Kotlin Multiplatform has been quite successful actually. The weakest part is Kotlin/Native where they could have just used GraalVM Native Image but I talked to the Kotlin team about that at the time and it was more of an opportunistic move than anything else - a compiler team in St Petersburg had been let go by Intel and JB could acquire the whole team cheaply.
Comment by yk09123 8 hours ago
Comment by Tyr42 23 hours ago
Comment by noelwelsh 12 hours ago
Comment by brabel 10 hours ago
Comment by noelwelsh 8 hours ago
Comment by brabel 2 hours ago
Comment by noelwelsh 14 minutes ago
"Sum and variant types are sometimes called disjoint unions. The type T1+T2 is a “union” of T1 and T2 in the sense that its elements include all the elements from T1 and T2. This union is disjoint because the sets of elements of T1 or T2 are tagged with inl or inr,respectively, before they are combined, so that it is always clear whether a given element of the union comes from T1 or T2. The phrase union type is also used to refer to untagged (non-disjoint) union types, described in §15.7." p142
"The dual notion of union types, T1 ∨ T2, also turns out to be quite useful. Unlike sum and variant types (which, confusingly, are sometimes also called “unions”), T1 ∨T2 denotes the ordinary union of the set of values belonging to T1 and the set of values belonging to T2, with no added tag to identify the origin of a given element." p207
Comment by esafak 1 day ago
Comment by cekrem 5 days ago
I think it's good nonetheless to add stuff to Kotlin that won't translate 1:1 to Java, both because Java is evolving but also because Kotlin is used in "Native" (non-JVM) contexts as well (not extensively, but hopefully that'll change).
Comment by wiseowise 11 hours ago
Comment by pjmlp 1 day ago
C++ cannot get away from C, Typescript cannot get away from JavaScript, and so forth.
Comment by zaphirplane 22 hours ago
Comment by pjmlp 21 hours ago
Ts is basically a linter, everything else is JS.
Comment by zaphirplane 13 hours ago
Comment by pjmlp 9 hours ago
Comment by ixtli 1 day ago
Comment by loglog 23 hours ago
If there were no checked exceptions to begin with, people might have thought about making the Java compiler (and later language server) infer all possible exception types in a method for us (as effect systems do). One could then have static analysis tools checking that only certain exception types escape a method, giving back checked exceptions without the type and syntax level bifurcation.
On the other hand, if all exceptions were checked, they would inevitably have had to implement generic checked exception types, ironically leading to the same outcome.
Comment by jessicest 2 hours ago
Comment by simon_void 5 days ago
Background: unions aren't restricted to one normal type and one error type, but to one normal type and any number of error types, so this can't be modelled as syntactic sugar on top of an implicit Either/Result type, can it??
Comment by pianoben 1 day ago
From the proposal discussion[0], the runtime representation on the JVM will just be `Object`.
[0]: https://github.com/Kotlin/KEEP/discussions/447#discussioncom...
Comment by ubertaco 4 hours ago
A `Result<T, E>` return type is way better.
This feels like it'll be viewed like Java's `Date` class: a mistake to be avoided.
Comment by pjmlp 1 day ago
The anti-Java bias, against the platform that made it possible in first place and got JetBrains a business, is quite strong on Android, fostered by the team own attitude usually using legacy Java samples vs Kotlin.
Comment by tadfisher 21 hours ago
There are many Kotlin features that do not have clean interop with Java; Compose, coroutines, and value classes come to mind. And it turns out that this mostly benefits Java, because these features are not built with the kind of engineering rigor that Java language features enjoy, and some of these features would behave way better with support in the VM anyway.
Where it makes sense, they are already moving closer to Java/JVM-native feature implementations; for example, data classes already have two-way support via records, and value classes are almost there (waiting on Valhalla GA).
Besides, wouldn't you want this stuff represented in the Java type system anyway? Otherwise you get the Lombok problem, where you have this build dependency that refuses to go away and becomes a de facto part of the language. Result<T, E> is not quite the same as rich errors which explicitly are not representable by user types.
Comment by vips7L 4 hours ago
They owe their users and customers. Many of them are asking for this and were lead to believe it was a priority when adopting the language.
Comment by pjmlp 14 hours ago
From all the JVM guest languages, possibly fostered by Android team, they are the most anti-Java ecosystem.
Clojure folks appreciate being a hosted language, Scala is kind of they would rather have Haskell but still JVM is kind of cool, Groovy was usually a way to script applications.
Kotlin, well those behave as if ever the JVM would be one day rewriten in Kotlin, they have Android for that.
The features you mention will never be supported in Android by the way, at least I don't believe Google will ever bother to do so.
Comment by rhdunn 9 hours ago
The two-way interop is one of Kotlin's advantages as it makes porting code from Java to Kotlin easier, or using existing Java libraries. For example, you don't have/need something like Scala's `asJava` and `asScala` mappers as the language/standard library does that mapping for you.
The interop isn't always perfect or clean due to the differences in the languages. But that's similar to writing virtual function tables in C -- you can do it, and have interop between C and C++ (such as with COM) but you often end up exposing internal details.
Comment by wiseowise 11 hours ago
It does. The language literally started as a “better Java” and has been marketed so for years until they’ve pivoted towards multi platform.
Comment by pianoben 22 hours ago
On backend teams, I've not personally encountered much anti-JVM bias - people seem to love the platform, but not necessarily the language.
(yes I know there's desugaring that brings a little bit of contemporary Java to Android by compiling new constructs into older bytecode, but it's piecemeal and not a general solution)
Comment by pjmlp 21 hours ago
They cherry pick whatever they feel like from OpenJDK.
And even though Oracle was right, given that Android is Google's J++, in this case they had better luck than Microsoft.
They don't take more from OpenJDK because then their anti-Java narrative doesn't work out.
But there is some schadenfreund, to keep Kotlin compatibility story relevant they are nonetheless obligated to keep up with is mostly used on Maven Central, thus the updates up to Java 17 subset.
Comment by pianoben 21 hours ago
J++ though, now that is a blast from the past! I think I still have a J# book from my student days, somewhere :)
Comment by pjmlp 14 hours ago
Kotlin only worked properly on Android after some folks pushed it from inside, and then they used Java 6 vs Kotlin samples to advocate for it.
In 2015 the latest Java version was 8, which never was properly supported on Android, the community had to come up with RetroLambda, before Google created desugaring support, think Babel but for Java.
Naturally it also meant that the performance of Java 8 features wasn't the same, e.g. lambdas make use of invokedynamic on the JVM, on Android they used to be rewriten into nested classes.
Even today, although Android documentation has Java and Kotlin tabs for code snippets, the Java ones are hardly taking advantage of modern features.
Naturally who learns Java on Android gets an adulterated view on the matter.
Comment by andrekandre 17 hours ago
> But I do remember when Kotlin broke on to the scene in 2015, and most of us were thrilled to finally move beyond Java 7!
n=1 but i was there with android studio v0.01 (or thereabouts) using kotlin for a production app cause i was so sick of old-java + eclipse... google was asleep at the wheele imo and android development would be nowhere near where it is today without jetbrainsComment by pjmlp 13 hours ago
None of the development environments is from Google, none of the languages as well, or the build tools for app developers (Internally they use Bazel and Soong).
Naturally having gone into bed with JetBrains for the IDE, after leaving NDK users without IDE tooling for almost two years during the IDE transition, the deal was in place to push Kotlin as well.
I am surprised Google hasn't yet bought JetBrains.
Comment by andrekandre 11 hours ago
> I am surprised Google hasn't yet bought JetBrains.
same, but i just guess google doesn't know what they would do with them outside of supporting android > Compared to Apple and Microsoft, Android development is mostly outsourced.
its true, but i wish apple worked harder on their ide because its so barebones compared to jetbrains its not even funnyComment by mike_hearn 4 hours ago
Comment by wiseowise 8 hours ago
Why would they? It’s the best of both worlds. They can pay a fraction of the price while having 100% of the benefits.
Comment by rileymichael 21 hours ago
Comment by dingi 23 hours ago
Comment by rhdunn 9 hours ago
You have to explicitly annotate that a Kotlin data class is a Java record due to the limitations Java has on records compared to data classes [1]. This is similar to adding nullable/not-null annotations in Java that are mapped to Kotlin's nullable/non-nullable types.
Where there is a clean 1-1 mapping and you are targeting the appropriate version of Java, the Kotlin compiler will emit the appropriate Java bytecode.
[1] https://kotlinlang.org/docs/jvm-records.html#declare-records...
Comment by tadfisher 21 hours ago
Comment by spankalee 22 hours ago
I'm building a new language, somewhat similar to TypeScript in some ways, and so far I have exceptions and try/catch expressions, but also Optional<T> and Result<T, E> types.
I'm familiar and used to exceptions, so I included them so at least near-fatal errors (ie, actually exceptional) could be caught at high levels in the stack. But I'm unsure if there's a strong argument that resonates with me yet that the language shouldn't have exceptions at all. Arguments that exceptions are untyped can be solved with things like checked exceptions, and I do find Go-style code to be quite verbose.
What's the best current reading on this?
Comment by mamcx 21 hours ago
The problem of mixing paradigms is that get confusing. Ideally all is represented equally (ie: All errors are `Result`) but is the handling that get confusing. Each option is a totally different control flow.
And it not compose (even if you use effects ) (and I mean ergonomically*) so you need to pick wich one to make first class
P.D: I'm pretty certain about the "not compose, in practice", I have seen lots of options and none looks nice, but open to corrections!
P.D.2: It should also consider the things on the dlang handling, and the midori article...
Comment by thesz 21 hours ago
> But I'm unsure if there's a strong argument that resonates with me yet that the language shouldn't have exceptions at all.
Result <E, T> is the type Either e t in Haskell. And Either is a Monad: https://hackage-content.haskell.org/package/base-4.22.0.0/do...This means you can have a computation inside Either e monad (notice missing result type) which can occasionally produce an exception, and these exceptions are checked (Either String cannot produce SNAFU-type exceptions, only textual descriptions of what was wrong).
So, if you are developing your language, please consider embedding it in Haskell first. That would allow you to experiment with different type representations, at the very least: Result <e, t> of yours is a Result (e, t) in Haskell, which is very distinct from Result e t (Either e t).
Comment by spankalee 20 hours ago
Comment by thesz 14 hours ago
[1] https://web.cecs.pdx.edu/~mpj/pubs/springschool95.pdf
This [2] paper generalizes exceptions, allowing them to be first class citizens of a language and even to be a exceptions-as-a-library.
[2] https://raw.githubusercontent.com/nbenton/nbenton.github.io/...
In (not only) my opinion, Haskell's greatness is in "what is a language feature in most languages is a library in Haskell." One can model type system of a language by embedding it as a library into a Haskell and then develop it further as a standalone language if one prefers such path.
I did that embed-as-library thing several times. It was of great help, especially in the embedded systems and hardware circuits domains.
Comment by hutao 19 hours ago
In Haskell, Either is defined as
data Either a b = Left a | Right b
Note that in Haskell, type parameters are lowercase. Source code: https://gitlab.haskell.org/ghc/ghc/-/blob/3f5e8d80b32063d265...Contrast the definition of the Result type in Swift:
public enum Result<Success: ~Copyable & ~Escapable, Failure: Error> {
/// A success, storing a `Success` value.
case success(Success)
/// A failure, storing a `Failure` value.
case failure(Failure)
}
Source code: https://github.com/swiftlang/swift/blob/256ff127c93d7e59a75f...I'm not sure what the parent commenter meant when they claimed that "Result <e, t> of yours is a Result (e, t) in Haskell." In Haskell, `(e, t)` would be the pair type (the binary product type).
Comment by thesz 15 hours ago
> The `Either a b` type in Haskell is equivalent to the `Result<T, Error>` type in other languages. The only difference is in the naming:
This is false and misleading.The Result <E, T> usually (C#, at the very least, most probably C++ and many other languages) should always be fully instantiated. One usually cannot construct a type "function" like Result <E,> that needs a single type argument to instantiate a full type. The partial application on type level is not there in most languages, including Rust (a result of a little googling).
The Haskell's Either type can be instantiated to Be a two-type-arguments function, one type argument function and, finally, a fully instantiated type like Either String Int.
This means that Result <E, T> type effectively has a single type argument, namely pair of types. The Either type has two type arguments and can be partially applied.
Comment by hutao 14 hours ago
In particular, higher-kinded types are necessary to abstract over functors (or functions from types to types, * -> *). The list type constructor is a functor, and the partially applied type constructor `Either a` is also a functor. However, in languages without higher-kinded types, type variables can only be "ground types" (of kind *).
I don't agree with this statement:
> This means that Result <E, T> type effectively has a single type argument, namely pair of types. The Either type has two type arguments and can be partially applied.
The Result<T, E> type still takes two type arguments. The main distinction, in my view, is that Haskell allows types to be "higher-order." In fact, to be really pedantic, you could argue that the `Either` type in Haskell really takes one type argument, and then returns a function from types to types (currying).
This is kind of like the type-level equivalent to how many programming languages support some notion of function or procedure (and functions may have multiple arguments), but only more modern languages support higher-order functions, or allow variables to be functions.
Comment by hutao 21 hours ago
Another relevant article is Robert Nystrom's "What Color is Your Function?": https://journal.stuffwithstuff.com/2015/02/01/what-color-is-... This article is about async/await, but the same principles apply to error handling. This article uses colors as an analogy, but is really about monads.
Both IO and exceptions can be denoted as a monad. What this means is that a function inside the programming language, A -> B, can actually be denoted by a mathematical function of the signature [[A]] -> M [[B]], for some monad M. For example, if we are dealing with the exception monad, M would be _ + Exception.
A language such as Java implicitly executes in the IO + exception monad. However, the monad can also be exposed to the programmer as an ordinary data type, which is what Haskell does. When people talk about the tradeoff of exceptions versus Result<T, E>, or the tradeoff between preemptive concurrency and async/await, they are really talking about the tradeoff between making the monad implicit or explicit. (A language where all functions may throw is like one where all functions implicitly return Result<T, E>. A language where all functions may be preempted is like one where all functions are implicitly async, and all function calls are implicitly await points.)
The theoretical technique of using monads to model the implicit effects of a programming language was pioneered by Eugenio Moggi, and the idea of making them explicit to the programmer was pioneered by Philip Wadler.
Something else to think about is how monads stack. For example, how would you handle functions that are both async/await and throw exceptions? Does the answer change when the monad is implicit (e.g. throwing exceptions) or explicit (e.g. returning a result)?
Comment by spankalee 21 hours ago
I think my biggest question might be addressed in the Midori article: with things like bounds checks and checked casts you already have exceptions (or panics), so should you have a way to capture them anywhere on the stack? Are they recoverable in some programs? So should you have try/catch even if you try to make most errors return values?
Another set of questions I have is around reified stacks. Once you have features like generators and async functions, and can switch stacks around, you're most of the way to resumable exceptions. I don't yet fully grok how code as the resume site is supposed to deal with a resume, but maybe resumable exceptions are a reason to keep them.
Comment by hutao 16 hours ago
[1] https://osa1.net/posts/2024-11-04-resumable-exceptions.html
[2] https://softwareengineering.stackexchange.com/questions/8033...
Comment by spankalee 14 hours ago
Comment by tome 11 hours ago
Comment by hutao 4 hours ago
Functions map inputs to outputs, with a type signature that looks like A -> B. Functions may be composed, so if you have f: A -> B and g: B -> C, you have gf: A -> C. Function composition corresponds with how "ordinary" programming is done by nesting expressions, like g(f(x)).
Sometimes, the function returns something like Option<B> or Future<B>. "Ordinary" function composition would expect the subsequent function's input type to be Future<B>, but frequently you need that input to have type B. Therefore, optionals or futures require "Kleisli composition," where given f: A -> Future<B> and g: B -> Future<C>, you have gf: A -> Future<C>. Kleisli composition corresponds with "monadic" programming, with "callback hell" or some syntactic sugar for it, like:
let y = await f(x);
g(y)
Effect handlers allow you to express the latter, "monadic" code, in the former, "direct style" of ordinary function calls.Comment by antonvs 9 hours ago
Not coincidentally, both provide a panic mechanism, which is intended for failures that are either unrecoverable or at least not locally recoverable. Both languages allow you to "catch" panics, but this mechanism is constrained and impractical to use for normal error handling. Instead, it's used to e.g. prevent a service from crashing if individual requests fail unrecoverably.
The point is that these languages both demonstrate a simple design for exception-free languages.
(Although Rust's approach is better in several ways, partly because it has a better type system.)
Comment by jpalepu33 23 hours ago
Having worked extensively with Node.js (callback hell, then Promises), I appreciate how error-as-value patterns force you to think about failure cases at every step. But the reality is most developers don't - they either:
1. Ignore the error case entirely (leading to silent failures) 2. Bubble everything up with generic error handling 3. Write defensive code that becomes unreadable
Rust's Result<T, E> with the ? operator found a sweet spot - you have to acknowledge errors exist, but the syntax doesn't make it painful. The key innovation is making the happy path concise while forcing acknowledgment of errors.
For Kotlin specifically, I'm curious how this interops with existing Java libraries that throw exceptions. That's always the challenge with these proposals - they work great in greenfield code but break down at library boundaries.
The real question: does this make developers write better error handling code, or just more verbose code? I'm cautiously optimistic.
Comment by HendrikHensen 23 hours ago
There would be real value in a language which would have both.
Error values are perfect for un-exceptional errors, e.g. some states of a business logic. The name that the user entered is invalid, some record is missing from the database, the user's country is not supported. Cases that are part of the business domain and that _must_ be handled, and therefore explicitly modeled.
Then there is the grey area of errors that one might expect (so not truly exceptional) but are not related to the business logic. These could be for example network timeouts, unexpected HTTP errors (like 503), etc. For those, there is often no explicit handling in the domain that makes sense. So it's convenient to just throw an exception, let it automatically "bubble" to the highest level (e.g. the HTTP controller) and just return some generic error (such as HTTP 500).
There are also truly exceptional cases, that you really shouldn't encounter in your program, such as null-dereferences, invalid array index access, division by zero, etc. These indicate a bug in the code (and might be introduced explicitly with assert-style checks). The program is in an unknown, compromised state, so there's really nothing left to do than throw an exception or panic. An error value makes very little sense in this case.
I often have the discussion with friends, why a division operator, or an array access, doesn't return a `Result` type in nice languages such as Rust? Surely, if they care about error values, then each operation that can fail, must return a `Result` rather than panic (throw an exception). It is an interesting through experiment at least.
Comment by josephg 22 hours ago
Rust has standard library functions to do this, if you want. arr.get(index) returns an Option. Integer types have .checked_div for panic-free divide. Float already doesn’t panic on an invalid division - it just returns NaN or Infinity.
Comment by HendrikHensen 22 hours ago
(Just to be clear, I don't really propose that a language should offer only panic-free operations; I just think it's a nice thought experiment and discussion to have).
Comment by josephg 15 hours ago
Comment by Tyr42 23 hours ago
I mean, this could be a syntax wrapper for java checked exceptions right?
Those are isomorphic to Result<_, Err> in that you must handle or propagate the error. The syntax is different, sure.
Comment by HendrikHensen 22 hours ago
Comment by vips7L 3 hours ago
Comment by frumplestlatz 12 hours ago
Of course it's convenient to be able to ignore error paths when you're writing code. It's also a lot less convenient when those error paths cause unexpected runtime failures and data corruption in production.
A preference for unchecked exceptions is one of my most basic litmus tests for whether a developer prioritizes thinking deeply about invariants and fully modeling system behavior. Those that don't, write buggy code.
Comment by hackthemack 22 hours ago
One thing I notice in enterprise java software that I have to reed through and update, is that too many times, every developer just wraps everything in an exception. I do not have vast insight into all java code, everywhere, but in my little corner of the world, it sure looks like laziness when I have to dig through some ancient java code base.