What Ceylon's Union and Intersection Types Taught Us About Expressive Static Typing
Ceylon's union and intersection types with principal typing offered expressive static typing on the JVM — flow-sensitive narrowing, ad-hoc polymorphism without inheritance — but complexity explosion and tooling death limited adoption. Modern languages kept the ergonomics, dropped the baggage.
05 Nov 2025, 01:50 UTC

The Problem: Expressing "One of These" or "All of These" Without Inheritance
You've written a function that should accept either a String or an Integer — two types with no common supertype beyond Object. In Java, you'd reach for overloading, a visitor pattern, or Object with runtime checks. In Kotlin or Scala, you might use a sealed hierarchy. But what if the types aren't yours to modify, or the combination is genuinely ad-hoc?
Ceylon, a JVM language that shipped its last release in 2017, took a different bet: make union types (A|B) and intersection types (A&B) first-class citizens in the type system, backed by principal typing — the guarantee that every expression has a unique, most-specific type the compiler can infer. The experiment produced genuinely useful ergonomics, but also revealed why this approach hasn't become mainstream.
Union Types: Flow-Sensitive Narrowing in Practice
In Ceylon, a variable can hold a value of type String|Integer without any wrapper. The compiler tracks control flow to narrow the type automatically:
shared void printLength(String|Integer input) {
if (is String input) {
// input narrowed to String here
print(input.size);
} else {
// input narrowed to Integer here
print(input.string.size);
}
}
The is check acts as a type guard. In the true branch, input has type String; in the false branch, the compiler computes String|Integer minus String, yielding Integer. No casts, no instanceof followed by explicit narrowing. This is flow-sensitive typing — the type of a variable changes based on the code path taken.
Principal typing means the compiler infers the most precise type for every expression. If you write String|Integer x = "hello", the inferred type of x is String, not String|Integer. The union only appears when control flow merges branches that produce different types.
Intersection Types: Multiple Constraints Without New Interfaces
Intersection types solve the dual problem: "this parameter must implement both Comparable and Serializable." Instead of creating a marker interface ComparableSerializable and retrofitting every class, you write:
shared void serializeAndSort(Comparable&Serializable[] items) {
// items implements both contracts
java.util.Arrays.sort(items);
// ... serialize
}
The caller passes any array whose element type satisfies both interfaces. No nominal coupling required. This is ad-hoc polymorphism — the function expresses its requirements structurally.
What the Compiler Actually Emits: Reified Generics and Synthetic Classes
Ceylon's module system packages full generic signatures (reified generics) into compiled modules. Union and intersection types survive compilation. For Java interop, the compiler generates synthetic interfaces and bridge methods. A function taking String|Integer compiles to a method accepting Object, with a synthetic Ceylon$Union$String$Integer interface that both String and Integer are made to implement via generated bridge classes.
You can see this with javap -v on the compiled .class files. The synthetic types appear in stack traces and reflection, which complicates debugging in mixed Ceylon/Java codebases. The distributive subtyping rule (A|B)&C ≡ (A&C)|(B&C) lets the compiler simplify complex expressions during inference, but the generated bytecode carries the expanded form.
The Trade-Off: Complexity That Didn't Scale
Three practical problems emerged:
- Principal type explosion: Deeply nested unions/intersections produce enormous inferred types. Error messages become unreadable, and compilation slows measurably.
- Tooling fragility: The last Ceylon release targeted Java 8. No maintained Maven or Gradle plugins exist. Reproducing a build today requires archived toolchains.
- Interop leakage: Synthetic classes leak into Java callers. A Java method calling Ceylon code sees
Ceylon$Union$String$Integerin signatures, notString|Integer.
These aren't theoretical — they're why the Ceylon test suite itself became a stress test for the type checker.
What Survives in Modern Languages
Ceylon's ideas migrated elsewhere. TypeScript's union/intersection types with control-flow narrowing (typeof, instanceof, user-defined type guards) are the direct descendant. Kotlin's type contracts and smart casts echo the flow-sensitive narrowing. Scala 3's union types (A | B) and intersection types (A & B) adopt the same distributive subtyping. Even Java's pattern matching for instanceof (JEP 394) delivers a subset of the narrowing ergonomics.
If you're designing an API today: use TypeScript's discriminated unions for ad-hoc variants, Kotlin's sealed classes when you control the hierarchy, and intersection types sparingly — only when the constraints are truly orthogonal and the callers can't share a nominal interface. The lesson isn't "use union types everywhere." It's "let the compiler narrow types from control flow, and express multiple constraints without forcing inheritance."
Try writing a small TypeScript function with a discriminated union and a type guard. Observe how the inferred type changes in each branch. That's the Ceylon idea, still alive and maintained.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.