Introduction
Back in the algebraic data types post, we built our "this or that" kind of type in the usual way: a common sealed trait, with as many case classes as the variants that we needed.
This is the standard approach that we Scala developers are used to, from a very long time. So much so that it became a sort of muscle memory: every time we need to model a domain, and we want to make sure that 1) it is closed for modifications and 2) it is checked by the compiler, we resort automatically to ADTs and sum types.
And while sum types are the right choice for domain concepts meant to live for years in you application, such as a payment state, it is undoubtedly a lot of boilerplate for a function that returns, say, a port number or a parse error. Nobody needs a named hierarchy for that. What we want is to write down "one of these two" in the return type, and move on with our day.
Scala 3 lets you do exactly that via a new feature called union type: an extremely lean syntax to describe exactly the concept of "this or that", without the inconvenience of a wrapper object at runtime. As usual, let's see first how the other three languages do that, this time starting with the language that got it there first.
TypeScript: the reference implementation
Unions are the backbone of how TypeScript models data, and narrowing them is the part of the language that feels best to use:
type ParseError = { message: string };
function parsePort(raw: string): number | ParseError {
const n = Number(raw);
return Number.isInteger(n) ? n : { message: "not a number: " + raw };
}
const result = parsePort("8080");
if (typeof result === "number") {
console.log(result + 1); // prints "8081", since it really is a number
} else {
console.log(result.message); // ParseError here, no cast needed
}There, typeof works there because one member is a primitive, while the other is not. When both members are objects, the check moves to a property name instead:
interface Port { value: number }
function parsePortStrict(raw: string): Port | ParseError {
const n = Number(raw);
return Number.isInteger(n) ? { value: n } : { message: "not a number: " + raw };
}
const result = parsePortStrict("8080");
if ("value" in result) {
console.log(result.value + 1); // prints "8081"
} else {
console.log(result.message); // ParseError here, no cast needed
}As you can see from both examples, the way types flow is pretty good: a typeof check, an in check, and the compiler tracks them through nested blocks.
However, TypeScript still suffers from the problem mentioned already in the pattern matching post: the compiler is not able to warn you if you miss a type. Covering every case is a manual chore you must remember, and do it by yourself:
type Payload = string | number | boolean;
function render(p: Payload): string {
if (typeof p === "string") return p;
if (typeof p === "number") return p.toFixed(2);
if (typeof p === "boolean") return p ? "yes" : "no";
const exhaustive: never = p; // the entire safety net, right here
return exhaustive;
}
// Add `| bigint` to Payload and that assignment is the line that fails.
// Delete that line and widening Payload breaks nothing, until it does.There is also another limit, tied this time on how structural typing works under the hood. These narrowing checks need something at runtime to be tested against and, by then, the types are just gone. Two members with the same shape have nothing to test:
interface Kilos { value: number }
interface Meters { value: number }
function describe(d: Kilos | Meters): string {
// no "typeof", no "in", not even "instanceof": what are we dealing with, here?
return String(d.value);
}So the usual advice is to put a literal kind field on every member, which is a hand-rolled tag on a union that the compiler could otherwise have kept apart on its own. Worth keeping in mind when we get to the Scala section, because Scala has a version of this problem too, and a different answer to it.
Python: syntax without enforcement
As we mentioned before, Python had Union[int, str] since the early typing days, and the much nicer int | str syntax since 3.10:
from dataclasses import dataclass
@dataclass(frozen=True)
class ParseError:
message: str
def parse_port(raw: str) -> int | ParseError:
try:
return int(raw)
except ValueError:
return ParseError(f"not a number: {raw}")
def describe(result: int | ParseError) -> str:
if isinstance(result, int):
return f"port {result}"
return result.messageDespite being more verbose of TypeScript's counterpart, the syntax works at runtime, and describe(...) can correctly assess whether we're dealing with an int, or a ParseError.
However, as we pointed out in the past, that is all we can squeeze out of the Python runtime. The interpreter will no proactively make sure that what we pass to describe(...) is indeed either an int, or a ParseError, unless you manually add and configure a type checker; leading towards very nasty runtime errors:
# completely valid Python code
describe(3.14)
# AttributeError: 'float' object has no attribute 'message'
#
# The float is not an int, so isinstance sends it down the second branch,
# where the code confidently reads .message off it.Because float is not an int, the runtime tries to execute result.message, but because that method does not exist on a float, we get that AttributeError.
Notice also where the crash happened: not where the function is invoked, but inside it. In a real application, that would most likely mean in two different files, several call stacks behind.
You could however have exhaustiveness check, the same way TypeScript does it:
from typing import assert_never
def describe(result: int | ParseError | None) -> str:
match result:
case int():
return f"port {result}"
case ParseError(message=m):
return m
case _:
assert_never(result)
# mypy: Argument 1 to "assert_never" has incompatible type "None"Which works but, once more, it's all manual, boilerplate code and setup that it's up to the developer's conscience to write down.
Java: the non-existing concept
Java has no union types, therefore there is no way to write a parameter or a return type meaning "an Integer or a ParseError" : the only route available, is the one we showed in the ADT post:
sealed interface Parsed permits Port, ParseError {}
record Port(int value) implements Parsed {}
record ParseError(String message) implements Parsed {}
static String describe(Parsed p) {
return switch (p) {
case Port port -> "port " + port.value();
case ParseError e -> e.message();
};
}Being a strongly-typed language, compiler enforces the exhaustiveness check in the switch statement, but let's look at what it took to get us there. An int cannot implement an interface, so the number had to be boxed into a Port record. Every producer wraps, every consumer unwraps, and the day you need to return an Integer or a String from some other function, you write a second sealed interface with two more wrapper records.
And this only works because we own both types. If we have two exceptions from two different libraries, those cannot be joined at all: you cannot retrofit permits onto classes compiled by somebody else. The fallback is resorting to Object, which gives up the check entirely:
static String describe(Object p) {
return switch (p) {
case Integer port -> "port " + port;
case ParseError e -> e.message();
default -> throw new IllegalArgumentException("unexpected: " + p);
};
}Because Object is not sealed, the default branch is now mandatory. The check moved from compile time to a runtime exception, and the developer has to trust the method body's word for what is actually allowed.
There is one exception (pun intended) where you can use union types in Java:
try {
connect();
} catch (IOException | TimeoutException e) {
// e is typed as the least upper bound of the two, i.e. Exception
log(e.getMessage());
}Multi-catch exception is a union type. But Java has restricted the concept in order to work, and being usable, precisely one position in the grammar.
Scala: union types as first class feature
Unlike Scala 3, in Scala 3 we can finally express the concept of "A or B" as A | B, which represents a whole, unnamed, new type:
case class ParseError(message: String)
def parsePort(raw: String): Int | ParseError =
raw.toIntOption match {
case Some(n) => n
case None => ParseError(s"not a number: $raw")
}
def describe(result: Int | ParseError): String = result match {
case port: Int => s"port $port"
case e: ParseError => e.message
}Unlike the Java case, here there is no wrapper type, no boxing at the call site, and no type hierarchy to declare anywhere. And, unlike TypeScript or Python, now you have full type check:
def describe(result: Int | ParseError): String = result match {
case port: Int => s"port $port"
}
// match may not be exhaustive.
// It would fail on pattern case: ParseError(_)The compiler knows the members of the union for the same reason it knows the children of a sealed trait: the list is written in the type. There is no never variable to remember, no assert_never to import, no lint rule to enable. As covered in the pattern matching post, out of the box that it produces a warning, and most build we encountered hardened their pipeline to fail the compilation if there are warnings, because they all pass -Werror.
Which also mean that widening a union becomes now a safe operation: add a member, and every match on it that has not been updated fails the build:
type Scalar = String | Double | Boolean
def render(s: Scalar): String = s match {
case s: String => s
case d: Double => d.toString
case b: Boolean => if b then "yes" else "no"
}
// Change Scalar to String | Double | Boolean | Long, and this match stops
// compiling, along with every other one in the codebase.Types you do not own
The two exceptions Java could not join, can be expressed now as a union type in Scala, which matches exhaustively:
import java.io.IOException
import java.util.concurrent.TimeoutException
def retryable(e: IOException | TimeoutException): Boolean = e match {
case _: TimeoutException => true
case io: IOException => io.getMessage.contains("reset")
}Both come from the JDK, both were compiled years before your project even started, and the signature still states exactly which two are allowed, with the compiler checking both the call and usage sites.
Be careful with opaque types
Because a Scala union type does not create wrappers, a value of Int | ParseError is just either the Int type or the ParseError type. This may have tricky consequences when used in conjunction with opaque types: members are discriminated by their runtime type, so two members with the same runtime type are, for all intent and purposes, regarded as one.
type Celsius = Double
type Fahrenheit = Double
def convert(t: Celsius | Fahrenheit): Double = ???
// Both aliases are Double, so this parameter's type is Double.
// The union collapsed, and nothing warned you.A union has no API of its own
A union is not a container, so there is nothing on it to call. Its members share no interface beyond the one every value in the language has, which leaves matching as the only thing you can do:
val r: Int | ParseError = parsePort("8080")
r.toString // it works, everything instance has this method
r.map(_ + 1) // error: value map is not a member of Int | ParseErrorWhen use a sealed trait instead
Unions do not replace algebraic data types, they cover the cases where ADTs are too heavy for. Reach for the sealed hierarchy when the concept deserves a name and a home in the domain model, when the variants carry the same payload and need tags, when you want methods or extensions on the type as a whole, or when somebody should be able to find every variant by opening one file. Reach for A | B when the combination is local, obvious from its members, or spans types you do not control.
What this changes for generated code
As we learned in the past posts, agents works best whenever the iteration loop is tight, and the agents can gather feedbacks sooner, rather than later. Creating a union type is a cheap and quick process, but ensuring it stays consistent and checked whenever it changes, and types gets added, is something else.
In Python and TypeScript, that edit leaves a codebase that runs. Every one of those call sites still passes review, still passes the test suite for the paths it already covered, and misbehaves only when the new member shows up in production. TypeScript can catch it at the call sites, but only where somebody previously remembered to write the never statement.
In Scala, widening a union is a compile error at every unhandled site, in the same working session, with the missing type named in the message. The agent gets the list of what it broke while it is still holding the context needed to fix it, rather than you getting it from an incident channel. That is the same property the sealed-trait version has had all along, now available without asking an agent to first invent a type hierarchy, three wrapper case classes, and a name for a concept that only exists inside one function signature.
Fewer lines to write, fewer lines in the context window, and the check is still the compiler's job rather than the reviewer's.
