$ \def\Vec#1{\mathbf{#1}} \def\vt#1{\Vec{v}_{#1}(t)} \def\v#1{\Vec{v}_{#1}} \def\vx#1{\Vec{x}_{#1}} \def\av{\bar{\Vec{v}}} \def\vdel{\Vec{\Delta}} $

Harald Kirsch

about this blog
2024-06-16

Java Wishlist: Union Types

This is the 2nd entry in my series about my Java wishlist, the Java features which I would like to see most. Some are already on the JEP list, some are not. The previous entry is:
  1. Thread Safety

Now lets talk about union types. When programming some non-trivial code in Typescript, I quickly learned to like union types, something like

type Stuff = number | boolean | { option: string }

And the meaning is pretty much what you expect. When you write

function f(stuff: Stuff): void {
  const lcase = stuff.option.toLowerCase();
}

the compiler immediately complains that at this point in the code stuff is not necessarily an object with an option field, but may be a number or boolean as well. You are forced, for good, to first verify what you have, before you can access the field. Something like

if ( typeof stuff === 'object' ) {
  const lcase = stuff.option.toLowerCase();
}

And ideally provide some code then to handle the other cases too. Though in this particular setting the compiler won't complain if you don't.

The union type Stuff quite simply defines that any value of this type is one of the given types and before accessing such a value, code must be provided which can tell what it actually is.

Compare this with Java exception handling:

} catch (InterruptedException | ParseException e) {
  if (e instanceof ParseException ee) {
     System.err.println(ee.getErrorOffset());
  }
}

It looks much the same: Exception e may be either of two exception types and access to the method specific to ParseException is only possible after we have made sure at which type exception we are looking.

But exceptions seem to be the only place where we can use this. But with the new pattern matching for switch expressions we may also write

private static void f(Object value) {
  switch (value) {
    case Integer i -> System.out.println("got Integer: " + i);
    case String s -> System.out.println("got String \"" + s + "\"");
    default -> System.out.println(
        "cannot prevent to get weird type: " + value.getClass());
  }
}

Sadly, the best we can do is declare the value parameter as Object. This forces us to add the default case with error handling. But, worse, the function prototype does not advertise that this method is only designed to handle Integer and String values. It would just be great to write

private static void f(Integer | String value) {

similar to the catch clause syntax.

Under some circumstances, the new sealed classes come to the rescue, as, depending on use case, we may be able to use something like:

sealed static class IorS permits Int, Str {};
static final class Int extends IorS { public int value; }
static final class Str extends IorS { public String value; }

private static void f(IorS value) {
  switch (value) {
    case Int i -> System.out.println("got Integer: " + i.value);
    case Str s -> System.out.println("got String \"" + s.value + "\"");
    case IorS weird -> System.err.println("weird" + weird);
  }
}

When using this, we have to obey some rules, though:

I am not a language designer, so I can't tell how difficult this would be to implement or if it is possible at all without introducing extremely ugly cases. But well: wishes. :-)