Ceylon's Union and Intersection Types: Modeling Optionality Without Null Checks
Ceylon models nullable types as T|Null unions and uses intersection types for mixin composition—no Optional wrappers, no inheritance hierarchies. The compiler infers principal types locally, but the language hasn't been updated since 2017.
03 Jul 2026, 10:45 UTC

The Problem: Null Checks Everywhere
Most languages treat null as a special case—a hole in the type system that forces defensive checks at runtime. Java's Optional<T> helps but adds wrapper overhead and doesn't compose with other types. Ceylon takes a different approach: it models optionality as a union type T|Null, making null a first-class type that participates in the same subtyping rules as everything else.
This isn't just syntactic sugar. Union and intersection types give you ad-hoc polymorphism without inheritance hierarchies, and the compiler infers principal types for local variables so you rarely write annotations. The trade-off is a compiler that can produce surprisingly large inferred types in error messages, and a language whose tooling hasn't been updated since 2017.
Nullable Types as Unions
In Ceylon, String? is syntactic sugar for String|Null. The | operator forms a union type: a value of type A|B is either an A or a B (or both, if the types overlap). Because Null is a regular type with exactly one value (null), the same subtyping rules govern optionality as govern any other union.
shared void greet(String? name) {
if (exists name) {
print("Hello, " + name.uppercased);
} else {
print("Hello, stranger");
}
}The exists check narrows the type of name from String|Null to String inside the guarded block. No explicit cast, no Optional unwrapping. The compiler knows name cannot be Null after the check because Null has no instances that satisfy exists.
Intersection Types for Mixin Composition
Intersection types A&B express values that satisfy multiple interfaces simultaneously. This enables mixin-style composition without a common supertype or explicit implementation class.
interface Printable {
shared formal void print();
}
interface Serializable {
shared formal String serialize();
}
class Data(String value) satisfies Printable & Serializable {
shared actual void print() => print(value);
shared actual String serialize() => "data:" + value;
}
shared void process(Printable&Serializable item) {
item.print();
print(item.serialize());
}The parameter item must implement both interfaces, but Data doesn't inherit from a combined interface—it just satisfies both. Any class that implements Printable and Serializable works, even if written years apart in different modules.
Principal Typing and Local Inference
Ceylon computes principal types for local variables. When you write value x = someExpression, the compiler infers the most precise type—often a union or intersection—without annotations.
value results = {
"success",
42,
null,
Data("test")
};
// Inferred type: {String, Integer, Null, Data}The inferred type is a union of all element types. You can iterate and narrow with if (is String s) or if (is Data d) checks. The compiler tracks the narrowed type in each branch.
Worked Example: Parsing a Heterogeneous Config
Suppose you parse a JSON-like config where a field can be a string, integer, boolean, or missing. In Java you'd use Object and instanceof chains. In Ceylon:
shared void parseConfig(Map config) {
value timeout = config["timeout"];
// timeout has type String|Integer|Boolean|Null
if (is Integer i = timeout) {
print("Timeout: " + i + "ms");
} else if (is String s = timeout) {
print("Timeout string: " + s);
} else {
print("No timeout configured");
}
}The is Type name = expression syntax declares a new narrowed variable in scope. Each branch sees a precise type. No casting, no unchecked warnings.
To try this, install Ceylon 1.3.3 from the archived GitHub releases, create a module with module.example "1.0.0" {} in module.ceylon, put the code in source/example/run.ceylon, then run:
ceylon compile
ceylon run exampleRequires a JDK 8+ on PATH. The ceylon CLI manages its own module repository in ~/.ceylon/repo. No Maven or Gradle plugins exist; the native toolchain is the only supported workflow.
Trade-offs and Limitations
Union and intersection types increase compiler complexity. Error messages can show inferred types like String|Integer|Boolean|Null|Data|Printable&Serializable, which are technically precise but hard to read. The compiler mitigates this with type aliases, but you'll still encounter them in diagnostics.
Java interoperability requires explicit bridging. Java's erased generics and unchecked nulls don't map cleanly to Ceylon's reified unions. Calling Java code from Ceylon means writing wrapper functions that convert T|Null to @Nullable T and back.
The biggest limitation: Ceylon 1.3.3 (2017) is the last release. The language is no longer actively developed. IDE plugins (IntelliJ, Eclipse) are unmaintained. The test suite in the source distribution (com.redhat.ceylon.compiler.typechecker) still passes, but no security updates or JDK compatibility fixes are coming.
Checking the Result
After compiling, inspect the generated module archive:
ceylon info example/1.0.0This shows how union types are encoded in module metadata. Compare against the Ceylon Language Specification v1.3 sections 3.4 (Union types), 3.5 (Intersection types), and 4.5 (Type inference) to verify compiler behavior matches the spec.
If you're evaluating Ceylon for a new project, the type system is genuinely novel and useful—but the ecosystem is frozen. For production work, consider whether the typing benefits outweigh the maintenance risk. For learning type system design, it's still one of the clearest implementations of union/intersection types with principal typing in a mainstream-adjacent language.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.