Model Closed Variants in Ceylon with `of` and Exhaustive `switch`
Ceylon’s `of` clause creates closed variant sets and its exhaustive `switch` turns adding a new case into a compiler-guided refactor instead of a code-review gamble.
09 Jul 2025, 13:25 UTC

The missing-case problem
You add a new variant to a domain model, run the tests, and everything passes. Weeks later a customer hits a blank screen because one rendering path never learned about the new case. The bug is not a missing unit test; it is an open class hierarchy where the compiler cannot tell you which consumers must change.
Ceylon offers a different default: declare a closed set of variants with an of clause, and let an exhaustive switch make the compiler—not code review—the enforcement point.
Closed variant sets with of
In Ceylon you can declare an abstract class whose subclasses are fixed at compile time:
abstract class Shape() of Circle | Square {}
class Circle(radius Float) extends Shape() {}
class Square(side Float) extends Shape() {}
The of clause turns Shape into an enumerated type. Another module cannot introduce class Triangle extends Shape(); the compiler rejects it. That is the core invariant: the set of variants is closed within the declaring module.
This is not the same as a Java enum. Each variant is a full class with its own state and initializer parameters, but the set of variants is fixed.
Flow-sensitive narrowing without casts
Ceylon also treats union types as first-class values. You can give a name to “one of these types” with alias:
alias JsonValue = String | Integer | Float | Boolean | Null;
Inside a branch, the compiler refines the type. After if (is String s) { ... } or if (exists s) { ... }, s is known to be non-null and of the expected type. No explicit cast is needed, and the null check is part of the type system rather than a runtime convention.
Because if/else and let (x = expr) expr else default are expressions, you can return a computed default inline instead of scattering statement-level null checks.
Exhaustive switch as a refactoring tool
When you switch over an enumerated type, Ceylon requires every variant to be handled. Omitting a case is a compile error. Adding a new variant to the of clause therefore breaks every switch that touches that type until the author updates it.
That converts “add a variant” from a silent, review-dependent task into a compiler-guided refactor across the codebase.
A worked example
Assume a module with the Shape hierarchy above and an area function. The syntax below reflects the language’s documented behavior; confirm details against your installed Ceylon version.
Float area(Shape shape) {
switch (shape)
case (is Circle c) {
return 3.14159 * c.radius * c.radius;
}
case (is Square s) {
return s.side * s.side;
}
}
Now add a third variant:
abstract class Shape() of Circle | Square | Triangle {}
Every switch over Shape—including area—fails to compile until a case (is Triangle t) branch is added. The error is local to each handler, so the blast radius is visible immediately.
Diagnostic decision: if the compiler reports a non-exhaustive switch, treat it as a required refactor, not a warning. Either handle the new variant or explicitly map it to an existing behavior with a comment explaining why.
Trade-offs and where the guarantees weaken
Closed variant sets are powerful for invariants, but they block open extension APIs. If you need third-party plugins to add cases at runtime, an of clause is the wrong tool; you will need a registry or visitor pattern that tolerates unknown variants.
Java interop is another boundary. Values arriving from Java are platform types, so Ceylon’s null-safety does not automatically apply. Re-establish the invariant at the seam with ?, exists, or an else default before the value enters your closed-variant logic.
Wide unions can also produce long switch blocks. Extract per-case functions so each handler stays small and testable. And because named arguments and default parameters are idiomatic in Ceylon, mixing them with Java call sites can surprise readers new to the language.
Check the result before committing
Ceylon’s distribution and IDE support have changed over time, so confirm the toolchain on your JDK before promising a build.
- Run
ceylon --versionin your terminal. Expected check: a version string is printed. Risk: the CLI may not be on yourPATH. - Create a minimal module containing the
Shapeexample and runceylon compile <module-name>from the project root (the directory containingmodule.ceylon). Replace<module-name>with the module name declared in that file. Expected check: compilation succeeds. Required permission: write access to the Ceylon output repository. - Delete one
casefrom theareafunction and recompile. Expected check: the compiler rejects the non-exhaustiveswitch. - Add
Triangleto theofclause and recompile. Expected check: everyswitchoverShapefails until updated.
Finally, inspect module.ceylon for dependency resolution and verify your editor has a working Ceylon plugin. If those checks pass, the compiler-enforced exhaustiveness model is a practical way to keep closed domain models honest as they evolve.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.