Practical use of Ceylon union and intersection types for JSON modeling
Learn how Ceylon’s union and intersection types let you model optional JSON fields and mixed‑concern traits directly in the type system, reducing boilerplate while keeping compile‑time safety.
28 Jun 2026, 02:40 UTC

Problem: mapping JSON to a domain model with optional fields and a serialization marker
When converting JSON payloads into typed objects, two common needs appear: a field that may be absent (e.g., a middle name) and a value that must carry both data and a behavioural marker (e.g., a "serializable" trait). In languages without native union or intersection types this often leads to wrapper classes, nullable types scattered through the code, or runtime casts that obscure intent and increase boilerplate.
Thesis: Ceylon’s union and intersection types let you express these concerns directly in the type system, reducing boilerplate while preserving compile‑time guarantees.
Union types for optional data
A union type A|B states that a variable can hold either an A or a B. In Ceylon, optional values are written with the ? sugar, which is equivalent to X|Null. After a runtime check such as if (exists x), the compiler refines the type to the non‑null member, so explicit null‑guards become unnecessary inside the branch.
Intersection types for behaviour markers
An intersection type A&B combines the members of both A and B. A class that implements a trait can be declared as ClassName&Trait, gaining access to all super‑type members plus the trait’s methods without creating a new wrapper class.
Worked example: parsing a JSON object into a Person model
import ceylon.json { JsonObject }
// Trait that adds a serialization method
interface JsonSerializable {
shared String serialize();
}
// Domain model with an optional middle name
class Person(String firstName,
String? middleName,
String lastName) {
shared String fullName =&nbs
firstName +
(if (exists middleName) " " + middleName else "") +
" " + lastName;
}
// Person that also satisfies the JsonSerializable trait via intersection
class JsonPerson(String firstName,
String? middleName,
String lastName)
extends Person(firstName, middleName, lastName)
satisfies JsonSerializable {
shared String serialize() =&nbs
JsonObject {
"firstName" -> firstName;
"middleName" -> middleName else null;
"lastName" -> lastName;
}.string;
}
// Parse a JSON object and produce a JsonPerson
void parsePerson(JsonObject json) {
value first = json"firstName" else throw Exception("missing firstName");
value middle = json"middleName" else null; // yields String? (nullable)
value last = json"lastName" else throw Exception("missing lastName");
value person = JsonPerson(first, middle, last); // intersection type Person & JsonSerializable
print(person.fullName);
print(person.serialize()); // available because of the intersection
}
The middleName parameter uses the optional type String? (equivalent to String|Null). When the code tests if (exists middleName), the compiler narrows the type to String inside the true branch, eliminating the need for an explicit null check before string concatenation. The JsonPerson class inherits from Person and satisfies JsonSerializable; the resulting intersection type Person & JsonSerializable lets us call fullName and serialize without any cast.
Trade‑off: learning curve and tooling maturity
Developers accustomed to nominal‑only type systems may need time to internalise union syntax (A|B) and intersection syntax (A&B), especially when flow‑sensitive refinement is involved. Additionally, IDE refactoring and debugging support for complex type expressions is less mature than for mainstream Java or Kotlin, which can make tracing type‑related errors slightly harder.
Practical way to check the result
- Install the Ceylon SDK version 1.3.3 or later (download from the official site and follow the platform‑specific setup).
- Save the code above as
source.ceylonin a new Ceylon module. - Open a terminal, navigate to the directory containing the module, and run:
ceylon run --strict source.ceylon
If the compilation succeeds, the program will print the person’s full name and a JSON string representation. To verify that the compiler catches type mismatches, change the assignment of middle to an Integer and recompile; you should receive a clear error message at the line where the type mismatch occurs.
Limitations
- Union and intersection types increase the learning curve for teams transitioning from nominal‑only type systems.
- Tooling support (IDE refactoring, step‑through debugging) for intricate type expressions is not as developed as for Java or Kotlin, so locating type‑related bugs may require extra manual inspection.
- The
?sugar for optionality is limited to nullable types; representing “absent” values without a null runtime representation requires theEmptytype, which has no value and cannot be tested withexists.
Closing: start small, reap safety gains
Replace a nullable JSON field with a String? parameter and use if (exists x) to let the compiler refine the type. When you need a behaviour marker, try an intersection type such as ClassName & Trait instead of creating a wrapper class. These patterns give you more precise types with minimal runtime overhead, and the compiler will immediately flag mismatches, helping you catch bugs early.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.