Ceylon’s Union and Intersection Types: Expressive Static Typing Without Runtime Cost
Ceylon’s first‑class union and intersection types give precise, flow‑sensitive static typing without runtime wrappers. This blog shows the syntax, narrowing behavior, and why the discontinued language still illustrates a powerful design.
08 Oct 2026, 19:28 UTC

Why Union and Intersection Types Matter
Most JVM languages model “a value that can be one of several things” with either a class hierarchy, a visitor pattern, or a wrapper like Optional<T>. All of those add boilerplate and, in the case of wrappers, an extra heap allocation. Ceylon took a different engineering decision: make union (A|B) and intersection (A&B) types first‑class citizens in the static type system, then erase them to plain JVM bytecode so there is no runtime overhead.
Union Types Replace Optional Wrappers
In Ceylon a nullable string is simply String|Null. The compiler knows the exact shape of the value and can narrow the type after a check.
String|Null maybe = getUserInput();
if (exists maybe) { // `exists` checks for non‑Null
print(maybe.uppercased); // `maybe` is now typed as `String`
}
No Optional object is created, and the generated bytecode is a straight‑forward null check.
Flow‑Sensitive Narrowing in Practice
The same narrowing works for any union. A function that accepts a heterogeneous payload can be written without a visitor:
void process(String|Integer|Float payload) {
switch (payload)
case (is String) { print("String: " + payload); }
case (is Integer) { print("Int: " + payload); }
case (is Float) { print("Float: " + payload); }
}
The switch is exhaustive; the compiler errors if a new union member is added but not handled. After each case the variable’s type is narrowed to the matched member, so member access is safe without casts.
Intersection Types for Ad‑hoc Composition
When an API needs an object that satisfies several contracts, Ceylon lets you compose them on the spot:
interface Serializable {}
interface Comparable {}
void store(Serializable&Comparable item) { … }
class DataPoint() satisfies Serializable & Comparable { … }
store(DataPoint()); // OK, DataPoint implements both
No new nominal interface is required. This is handy for library boundaries where you cannot modify existing classes.
Trade‑offs and Current Reality
- Erasure on the JVM: Union and intersection types compile to raw types with synthetic bridge methods. Reflection sees only the erased signatures, so runtime introspection loses the precise typing.
- Discontinued toolchain: The last release (1.3.3, 2017) targets JDK 8. No updates for JDK 17+, and the Eclipse‑based IDE is abandoned.
- Java interop friction: Java’s
nullmaps to Ceylon’sNulltype, and Java generics lose variance information at the boundary.
You can verify the erasure yourself: compile the snippets with the archived Ceylon 1.3.3 distribution (ceylon compile) and inspect the class files with javap -v. You’ll see bridge methods for each union member but no distinct runtime type.
What to Do Next
If you are evaluating language features for a new project, consider modern alternatives that keep the same ideas alive: Kotlin’s sealed classes with pattern matching, TypeScript’s discriminated unions, or Scala 3’s union/intersection types. For pure learning, download the Ceylon 1.3.3 SDK from the Eclipse GitHub releases, run the examples above, and compare the generated bytecode with a Java equivalent. That hands‑on check confirms the zero‑overhead claim and shows the limits of reflection‑based tooling.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.