Diagnosing Null-Related Type Errors in Ceylon Code That Uses Union Types
A diagnostic guide for Ceylon programs where a nullable union type (String?) slips past apparent null checks: how flow-based narrowing fails, how to find the break, and which fix matches each cause.
28 Apr 2026, 07:15 UTC

The condition: a null slips past your type checks
You have a Ceylon program where a value is declared with a nullable type such as String? (shorthand for String|Null). You believe you have guarded every use with a null check, yet at runtime the program still fails when a method is invoked on the value, or the compiler refuses to accept code that looks obviously safe. This is a recognizable pattern in Ceylon codebases that lean heavily on union types and generics: the static flow analysis that normally narrows String? to String has silently failed to eliminate the Null branch.
The useful takeaway: in Ceylon, null safety is enforced by type narrowing, and narrowing is flow-sensitive. Anything that interrupts the compiler's view of the control flow — assignments inside closures, mutable variable values captured elsewhere, or inference through generic type arguments — can leave the Null branch in play. The fix is almost always to make the narrowing explicit and local.
Cause and diagnostic summary
| Symptom | Likely cause | First check |
|---|---|---|
Compiler error: expression has type String? where String is required | Missing or ineffective narrowing of a union with Null | Confirm an if (exists x) or else branch actually covers the call site |
| Check passes in one function but fails after refactoring | Value became a captured variable, so flow analysis can no longer guarantee it is unchanged | Look for variable annotations and closure captures of the value |
| Error only appears inside generic code | Type inference produced a union broader than expected, e.g. String|Null instead of String | Inspect the inferred type argument; add an explicit type argument to test |
| Runtime failure despite apparent null check | Check applied to a different binding than the one used at the call site | Trace the exact declaration each usage resolves to |
Ordered checks
- Read the declared type literally. In Ceylon,
String?meansString|Null. Any parameter, return type, orvariablewith that type can hold null regardless of how the surrounding code reads. Start at the declaration, not the call site. - Check that narrowing is syntactically adjacent to the use. Flow-based narrowing in Ceylon applies within the scope of an
if (exists x),if (is String x), or aswitchcase. If the value is used in a closure, a later statement after a possible reassignment, or a different function, the narrowing does not carry over. - Look for
variabledeclarations. The compiler cannot narrow a mutable value that might be reassigned between the check and the use. This is the single most common reason an apparently guarded nullable still triggers an error. - Check generic inference. If the value flows through a generic function or collection, print or infer the type argument explicitly. A call like
list.firstonList<String>yieldsString?because the list might be empty — the null is coming from the API contract, not your data. - Build a minimal reproduction. Reduce the failing code to a single function of a few lines. If the error disappears, the cause is in the control flow you removed — reintroduce it piece by piece.
Fixes tied to findings
Narrow explicitly with exists
If the value is only used when non-null, narrow it at the point of use:
String? maybeName = ...;
if (exists name = maybeName) {
print(name.uppercased); // name: String here
}
Binding to a fresh name (exists name = maybeName) is more robust than exists maybeName when maybeName is a variable, because the new binding is immutable and the narrowing cannot be invalidated by a later assignment.
Substitute a default with else
When a sensible fallback exists, eliminate the null branch before the call:
String name = maybeName else "anonymous";
print(name.uppercased);
The else operator has type String here, so no further narrowing is needed. This is usually the cleanest fix for values coming from APIs like List.first or Map.get.
Make mutability honest
If a variable is the problem, either remove variable if reassignment isn't actually needed, or copy to an immutable local before narrowing:
variable String? current = ...;
if (exists snapshot = current) {
// snapshot is immutable; safe to use in closures
process(() => snapshot.size);
}
Widen the receiving type instead
Sometimes the right fix is to accept that null is meaningful and change the downstream signature to String?, pushing the handling decision to the caller. Prefer this over sprinkling defaults when "no value" is a real domain state.
Version assumptions and verification
This guidance assumes Ceylon 1.3.x, where exists/is narrowing and the else operator behave as described. Narrowing behavior for captured variables and some generic inference details changed across releases, so confirm against the compiler you actually build with. Treat any claim about specific compiler warning flags with suspicion and check ceylon compile --help for the diagnostics your version supports — do not assume a particular flag exists.
To verify a fix: recompile the module and confirm the type error is gone, run the module's tests, and exercise the previously failing path with an actual null input (for example, an empty list reaching list.first). If the failure was a runtime crash rather than a compile error, the minimal reproduction from check 5 is your regression test — keep it.
When to escalate
Escalate beyond local fixes when: the same nullable flows through three or more functions and each one re-implements null handling (a sign the type should be non-null at the source); the null originates from interop with Java code, where Ceylon cannot see nullability annotations and you may need a boundary adapter; or the inference problem sits inside a widely used generic utility, where a wrong explicit type argument could mask errors for every caller. In those cases, redesign the type at the boundary rather than adding one more else.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.