Hacker News new | ask | show | jobs
by dnautics 21 days ago
what I like least about this article is how people seem to just substitute some other, usually theory, notion of what function coloring is to elide the argument (usually to excuse their favorite PL) without actually RTFA. The article is about ergonomics, not PL theory.

> Exceptions cause function coloring

do they? Do they?

1) Every function has a color

2) The way you call a function depends on its color

3) You can only call a red function from within another red function

4) Red functions are more painful to call

5) Some core library functions are red

2 comments

Java's checked exceptions fit the 5 criteria:

1. It either `throws` or it doesn't

2. If the function `throws` you have to wrap it in try/catch, or make your function `throws`

3. Your function is `red` if it `throws` the same exception.

4. see (2)

5. See the FileReader class in core.

Now, C++ exceptions might not satisfy all of these, but the problems CheckedExceptions were meant to solve still exist in C++ and as a result some style guides forbid them entirely. Like async, the biggest problem with exceptions were the ergonomics.

> Like async, the biggest problem with exceptions were the ergonomics.

I know it's not a popular take, but I prefer the idea of Checked Exceptions over unchecked ones [0], and suspect current opinions would be vastly different if Java had shipped with some sweet syntactic sugar for: "If an exception that is of kind A or B or C occurs, automatically throw another checked exception X with the original exception as a cause."

> Ex: If I'm writing a tool to try to analyze and recommend music that has to handle multiple different file types, I might catch an MP3 library's Mp3TagCorruptException and wrap it into my own FileFormatException.

This would reduce the temptation for developers to ruin the type-safety characteristics by wrapping everything in a RuntimeException just to get the ticket out the door.

[0] https://news.ycombinator.com/item?id=42946597

The problem with checked exceptions is that they don't compose with the rest of the type system. Hence the infamous problems with things like Streams. Result types have basically all the virtues of checked exceptions without the problems.
Result types do have one problem that checked exceptions don’t. Checked exceptions automatically combine into union types in a throws or catch clause. I haven’t seen a language that lets you be generic like that.

    T fn() throws E, F, G
vs

    Result<T, E | F | G> // not even Rust lets you do this.
That's more a consequence of Rust needing its tagged unions declared up front so it can lay them out consistently in memory without runtime type information. Python and TypeScript have untagged unions (that are discriminated at runtime by the RTTI attached to all objects in the underlying dynamic language); they don't happen to have an equivalent of Rust's ? operator, but if they did it'd work like you're describing.
"untagged union" usually means "no discriminant" not "runtime discriminant."

(Rust has both tagged (enum) and untagged (union) unions, but untagged ones are unsafe and therefore mostly used for C interop and similar cases.)

The E | F | G could be two different features, "anonymous sum types" or "union types".

TypeScript is an example of a language with union types: https://www.typescriptlang.org/docs/handbook/unions-and-inte...

The problem with Java's checked exceptions is that it has too many kinds of exceptions to choose from and they're overly specific. Compare with Go, which has a single error interface and had it from the beginning, so it's used everywhere. Returning a new kind of error is always a local change, unless it's a function that didn't previously report errors at all.

Type systems permit either standardization or fragmentation and that's an ecosystem issue. Another example is that a language without a strong consensus on which string type to use will result in a fragmented ecosystem when each library goes its own way.

> too many kinds of exceptions to choose from

I don't understand, why would you need to pick a checked exception? It's the dual or mirror of feeling paralyzed over a return-type because there are "too many kinds of Object to choose from."

If you're writing a CrystalBall class with a gaze_deeply() method, you'll probably return your own VisionResult (extends Object) unless it throws your TooCloudedException (extends Exception).

When someone else writes a wrapper or higher-level layer that uses your code, then it'll be up to them to convert or wrap those results and exceptions into something suitable for their level of abstraction.

> Returning a new kind of error is always a local change

One of my axioms here is that return-values and checked-exceptions are two sides of the same architectural type-system coin. While I'm not familiar with Go, that sounds like something that would be a symptom of bad architecture if it occurred for return values.

In other words, suppose all Java methods always returned Object [0]. That would also ensure that a new return type is "always a local change" to the compiler, but I think most developers would be rightly horrified if they came across code that worked that way.

[0] Let's ignore Java primitives for now.

> you'll probably return your own VisionResult (extends Object) unless it throws your TooCloudedException (extends Exception).

> When someone else writes a wrapper or higher-level layer that uses your code, then it'll be up to them to convert or wrap those results and exceptions into something suitable for their level of abstraction.

Why though? What do you gain other than longer stacktraces with all those wrappers? People always trot out some theoretical notion that a caller is going to catch that framework's different exceptions and handle them differently, but have you ever seen calling code that actually did that?

> In other words, suppose all Java methods always returned Object [0]. That would also ensure that a new return type is "always a local change" to the compiler, but I think most developers would be rightly horrified if they came across code that worked that way.

There are many different kinds of values. There really aren't that many different kinds of error - there's "transient error that you might want to retry", "programmer called the API wrong", and that's about it, most other cases (like bad user input) probably shouldn't be exceptions.

> Why though? What do you gain other than longer stacktraces with all those wrappers? People always trot out some theoretical notion that a caller is going to catch that framework's different exceptions and handle them differently, but have you ever seen calling code that actually did that?

You've never seen a try that has more than 1 catch block for different exception types?

> There are many different kinds of values. There really aren't that many different kinds of error - there's "transient error that you might want to retry", "programmer called the API wrong", and that's about it, most other cases (like bad user input) probably shouldn't be exceptions.

Do you think bad user input should be a result type? Because exceptions are essentially the same thing.

You've hit on a couple of problems with exceptions in Java though. The first is I think the default for checked exceptions should be no stack trace. As the designer of the method you've left it to the caller to decide to handle it or not, if they choose to turn it into an unchecked exception then I believe that is where the stack trace should start from. Assuming there's enough context in the checked exception the designer of the method gave you everything you needed to handle it so why do we need to capture that part of the stack trace? If it ends up getting logged the source of the issue was where it was changed to an unchecked exception.

The other issue comes down to usability. Try catch blocks aren't expressions so if you want to default something in the case of a checked exception it's a lot of low information density lines. Converting to an unchecked exception is also more ceremony than it really needs to be, but there's not really a reason why it couldn't be made simpler with some syntax sugar.

> Why though?

I'm not sure if this means:

1. "Why bother throwing a new exception of a different class, and not bubble up the original as-is?"

2. "Why would you use the standard feature of all Java exceptions which allows you to chain them, and not just throw away the original exception after copying some of its message string?"

For #1, it should be obvious in almost any language, if not instinctive: The library for managing customer records should return `Customer` objects instead `some.database.FetchResult` ones, and likewise it should throw `CustomerAccessException` instead of a `some.database.DatabaseException`. It's a matter of abstraction and preventing weird coupling.

For #2, surely you've been debugging something before and cursed at how the log is missing crucial details that could have saved you hours of trying to reproduce the problem? By chaining exceptions, you get automatic access the inner exception type, message string, additional properties, and deeper stack trace.

* Discarding the 'cause' at this point is a waste, the work was already done, the memory already allocated, why not benefit from it?

* Sometimes it's not your bug, and having the inner exception makes it much easier to get the necessary cooperation of someone else and get it fixed faster.

* Since the declared type is Throwable, it's not creating a compile-time dependency between layers. You could do a softer runtime test on the cause's type, but usually that's a temporary workaround while you complain that someone else's code is hiding critical details.

> there really aren't that many different kinds of error

I feel that's naive, handling edge cases and errors becomes more important as any system gets larger. Some are emergent from the complexity, others always existed but we can't afford to ignore them anymore.

There are many errors because all errors are contextual! Bad-input in the HTML form-submit is not the same as bad-input in the SQL query. A failed invariant of a tree that somehow made a loop is not the same as a failed invariant of something reporting negative length. An SSH handshake error is not a TLS handshake error.

This isn't like returning Object. It's more like returning a String. After using a language with a common String type, who wants to go back to writing code to convert between between different kinds of strings? Having to choose among different string implementations because there's no standard usually leads to boilerplate code doing conversions at the borders.

Usually you just want to propagate or log errors, so having a generic error interface is sufficient. It's true that in Java, you can wrap exceptions, but that's extra boilerplate.

(And yes, Go does notoriously have error propagation boilerplate that they should fix, but that isn't a type system problem.)

Exceptions are a very good comparison because they also perform non-local control flow.

Checked exceptions are a form of coloring, while unchecked aren't. But Java (which has checked exceptions) has an escape into unchecked land in the form of RuntimeError, most async languages do not, short of spawning a background thread (for sync->async) or force blocking (async->sync).

Interestingly, Result<T,E> based error models are semantically (and even syntactically, mostly, except for the call-site annotation) equivalent to checked exceptions. Usually these languages have enough abstraction capabilities (HKT for example) to make coloring not an issue, or, again, an escape into unchecked land (for example panic in rust or Go, although the latter hardly counts as having Result-like error handling).

#3 is not satisfied, as you noted in #2. You can call `throws` methods from non-`throws` methods by wrapping the call in a try catch, and `throws` methods can call non-`throws`. There isn't an exclusivity asymmetry like there is for JavaScript async.
That only applies to Javascript, which, is mostly only red functions anyways (there are no blocking apis in javascript). Javascript doesn't have the coloring problem in the way Python or Rust has it.

In Python, you can wrap the call with asyncio.to_thread, in rust with tokio::spawn_blocking.

I think you got it backwards: JavaScript has the coloring problem while other languages don't.

"Red functions are more painful to call" alludes to async functions. Every await yields back to the event loop, which adds overhead. Making every function red/async adds a performance cost (and makes it harder to reason about race conditions), which is why JavaScript has a mix of blue and red functions.

Other languages can escape the "red functions can only be called by red functions" trap, like Python asyncio.run or Tokio block_on. JavaScript has no such alternative, not even in Node. Therefore, Python and Rust don't have function coloring, but JavaScript does.

I think the big difference is that in an application that cares about throughput and being non-blocking simply blocking isn’t really possible as it affects the whole performance of the application. Wrapping a checked exception in an unchecked one doesn’t do that.
Ok, sorry it's been about 20 years since I last javad IIRC you didn't have to declare exceptions in your function signatures. However, wrapping in try/catch seems to violate #3. Try catch is not a heavy lift of a seam between red and blue

To be fair, #3 seems to have shades of grey. In some pls, you can call an async function from a sync one by wrapping it in a whole damn event loop system. Should that count?

> Ok, sorry it's been about 20 years since I last javad and you didn't have to declare exceptions in your function signatures.

You're probably remembering RuntimeExceptions, which are a subgroup [0] that are exempt from "checking" by the compiler, which means it does not require method signatures to declare "I might emit this."

[0] https://docs.oracle.com/en/java/javase/26/docs/api/java.base...

You can declare your runtime exceptions too. The compiler won’t enforce you to catch them though.

    void fn() throws IllegalStateException
Your question just kicks the can down the road. The problem with the article, to me, is the author doesn't want to accept a certain amount of complexity and has erected arbitrary road blocks.

To you, a "whole damn event loop system" is too high a price to pay, but try/catch is not. The complexity of exceptions is invisible to you. However there are certain environments (e.g. FFI) where I dont want "the whole damn exception runtime".

Maybe author do not want to hide the complexity in functions?
i mean no? the coloring problem appears to be solvable in zig. i maintain an FFI binding library for the BEAM vm, where once zig finishes its stackless coroutine support, the same zig function written once should be fully interchangeable between non-async, threaded-async, or async-with-yieldpoints-wrapped-in-the-BEAM's-scheduler.
> Try catch is not a heavy lift of a seam between red and blue

> To be fair, #3 seems to have shades of grey. In some pls, you can call an async function from a sync one by wrapping it in a whole damn event loop system. Should that count?

I think you have to count any extra overhead where you can't just write f(), including try/catch. It's always possible to call whatever kind of function from whatever other kind of function if you put enough effort and hackery in, so if we can't use functions of kind x in functions of kind y as normal "f()" function calls then that has to be what we mean by colouring.

Let's go through the list:

> 1) Every function has a color

Every function either throws an exception to indicate failure or doesn't. There's actually several different function colors available here, based on how failure is indicated: throwing exception, aborting the process, composite return value, error code return value, global errno-like variable, error code as a parameter, ....

> 2) The way you call a function depends on its color

See above.

> 3) You can only call a red function from within another red function

Some of the failure methods, like aborting on failure, cannot be converted to another mode at all (or only with very great difficulty). Others, like exceptions and errno-based routines, come with environmental constraints that could be contained by an error conversion routine in theory but may be precluded due to how the system as a whole works (e.g., a global variable errno doesn't play well with threads). Which isn't quite the same thing, but then again, "red function" here is async function, and the call-async-from-sync variant is the easier one to pull off (you spin the event loop), and has roughly the same issues as trying to box an exception routine: it only works if the system as a whole has mechanisms to make it work.

> 4) Red functions are more painful to call

Okay, you've got me here... the exception routines are the easier ones to call, syntactically than non-exception-based ones. Internally in the optimizer, however, exceptions are definitely the worst form (even errno somehow ends up working out better, and that's also deeply problematic).

> 5) Some core library functions are red

Oh yes, standard libraries love using a mix of all of these error-handling routines. Look up C++ <filesystem> for example.

Failing is a color, but throwing an exception isn't. An exception-throwing computation can easily embedded in a computation that doesn't throw - you can catch and return null, etc. But very rarely can a computation that may fail be part of a computation that may not fail.