Null Safety¶
Onion provides null safety features inspired by Kotlin, helping you avoid NullPointerException at compile time.
Nullable Types¶
By default, types in Onion cannot hold null. To allow null, use the ? suffix:
// Non-nullable: cannot be null
val name: String = "Alice"
// val name: String = null // Compile error!
// Nullable: can be null
val maybeName: String? = null // OK
val anotherName: String? = "Bob" // Also OK
Type Compatibility¶
TtoT?is allowed (widening)T?toTis not allowed (requires explicit handling)
val s: String = "hello"
val nullable: String? = s // OK: String → String?
val maybeS: String? = "world"
// val nonNull: String = maybeS // Compile error: String? → String
Safe Call Operator (?.)¶
The safe call operator allows you to safely access methods or properties on a nullable value. If the target is null, the entire expression returns null instead of throwing an exception.
Basic Usage¶
val s: String? = null
val upper: Object? = s?.toUpperCase() // Returns null, no exception!
val s2: String? = "hello"
val upper2: Object? = s2?.toUpperCase() // Returns "HELLO"
Method Calls with Arguments¶
val s: String? = "hello world"
val sub: Object? = s?.substring(0, 5) // Returns "hello"
val nullS: String? = null
val nullSub: Object? = nullS?.substring(0, 5) // Returns null
Safe Call on Non-Nullable Types¶
You can also use ?. on non-nullable types (though it's less useful since they can't be null):
Combining with Elvis Operator (?:)¶
The safe call operator works great with the Elvis operator for providing default values:
val name: String? = null
// If name is null, use "unknown"
val displayName: String = name ?: "unknown"
// Chain safe call with Elvis
val s: String? = null
val upper: String = s?.toUpperCase() ?: "DEFAULT" // Returns "DEFAULT"
val s2: String? = "hello"
val upper2: String = s2?.toUpperCase() ?: "DEFAULT" // Returns "HELLO"
Elvis with Nullable Primitives¶
The Elvis operator also works on nullable primitive types. The result is an
unboxed primitive, so it can be assigned straight to a non-nullable Int,
Long, Double, etc.:
This pairs naturally with a safe call to a primitive-returning method: the call
yields a boxed Integer?, and ?: supplies the primitive fallback when the
receiver is null:
val s: String? = null
val len: Int = s?.length() ?: -1 // -1 (s is null)
val s2: String? = "hello"
val len2: Int = s2?.length() ?: -1 // 5
Elvis with a Control Expression on the Right¶
The right-hand side of ?: can be any expression, including control-flow
expressions such as throw and return. This makes ?: a concise way to fail
fast or exit early when a value is missing:
def firstName(s: String?): String {
// If s is null, throw instead of producing a value
val name: String = s ?: throw new RuntimeException("nil")
return name
}
def lenOrDefault(s: String?, default: Int): Int {
// If s is null, return early from the method
val v: String = s ?: return default
return v.length()
}
Null Checking with if¶
You can check for null using standard if statements:
val name: String? = getUserName()
if name != null {
// Inside this block, we know name is not null
println("Hello, " + name)
} else {
println("Hello, guest")
}
Negated Conditions¶
!(cond) swaps which branch narrows — useful for early-exit style:
def f(s: String?): String {
if !(s != null) {
return "nil"
} else {
return "n" + s.length() // s narrowed to String here
}
}
if !(o is String) { return "not a string" }
Smart-Casting Nullable Fields¶
A null check narrows not only local variables but also immutable (val)
nullable fields. Inside an if field != null { ... } block, a val field of
type T? is treated as T, so you can call methods on it directly:
class Person {
val name: String?
public:
def this(name: String?) { this.name = name }
def nameLength(): Int {
if name != null {
return name.length() // name narrowed to String here
} else {
return -1
}
}
}
println(new Person("Alice").nameLength()) // 5
println(new Person(null).nameLength()) // -1
A mutable (var) field is not narrowed, because its value could change
between the check and the use (E0041 if you try). Snapshot it into a local
val first — locals are always narrowed:
class Counter {
var label: String?
public:
def this(label: String?) { this.label = label }
def show(): Int {
val l = label // snapshot into a local val
if l != null {
return l.length() // l narrowed to String
} else {
return -1
}
}
}
println(new Counter("hi").show()) // 2
println(new Counter(null).show()) // -1
A mutable (var) local is narrowed as well, as long as it is not reassigned
between the check and the use — so a var you assign once, or only reassign
elsewhere, still narrows where it matters:
def firstNonEmpty(lines: List[String]): String {
var found: String? = null
foreach line: String in lines {
if found == null && line.length() > 0 {
found = line
}
}
if found != null {
return found // found (a var) narrowed to String here
}
return "(none)"
}
This also covers the read-loop idiom while (line = next()) != null { ... },
where line is narrowed at the top of the loop body. A reassignment after the
use does not undo the narrowing, but a var captured by a closure stays nullable
inside the closure, since the closure may run after the variable has changed.
Where a null check narrows¶
A null check narrows wherever the checked value is guaranteed non-null by
control flow — not just the then branch of an if. All of these narrow x:
if x != null && x.length() > 0 { ... } // right operand of && (x is non-null there)
val ok = x == null || x.length() == 0 // right operand of || (x is non-null when the left is false)
while x != null { x.length() } // while body (runs only when x != null)
for var i: Int = 0; x != null; i = i + 1 { x.length() } // for body, same reason
select v { case s when s != null: s.length() ... } // a case body, guarded by its `when`
The while/for narrowing is flow-sensitive, so the idiomatic pointer-advance
loop works — a use before the advance is narrowed, and the reassignment clears
it from that point:
var cur: Node? = head
while cur != null {
visit(cur.value) // cur narrowed to Node here
cur = cur.next // advance; narrowing cleared past this point
}
Guarding with if x == null || cond { return } narrows x on the fall-through,
so an early-return guard leaves x non-null afterwards.
Return Type of Safe Calls¶
The return type of a safe call expression is always nullable (T?), since it can return null when the target is null:
val s: String? = "hello"
// toUpperCase() returns String, but s?.toUpperCase() returns String?
val upper: Object? = s?.toUpperCase()
For primitive return types, they are boxed (e.g., Int becomes Integer?):
val s: String? = "hello"
val len: Object? = s?.length() // Returns boxed Integer (5), not primitive int
Complete Example¶
class Main {
public:
static def main(args: String[]): void {
// Nullable type declaration
val maybeString: String? = null
println("maybeString is null: " + (maybeString == null))
val definiteString: String? = "hello"
println("definiteString is null: " + (definiteString == null))
// Safe call returns nullable result
val upper: Object? = definiteString?.toUpperCase()
if upper != null {
println("Upper: " + upper)
}
// Safe call on null returns null
val nullResult: Object? = maybeString?.toUpperCase()
if nullResult == null {
println("nullResult is null as expected")
}
// Safe call with arguments
val sub: Object? = definiteString?.substring(0, 3)
if sub != null {
println("Substring: " + sub)
}
}
}
Output:
maybeString is null: true
definiteString is null: false
Upper: HELLO
nullResult is null as expected
Substring: hel
Best Practices¶
- Prefer non-nullable types - Use
T?only whennullis a meaningful value - Use
?.for chains - Avoid nested null checks with safe call chains - Provide defaults with
?:- Use Elvis operator to provide sensible defaults - Check early - Validate nullable inputs at function boundaries
Comparison with Other Languages¶
| Feature | Onion | Kotlin | Java |
|---|---|---|---|
| Nullable type | T? |
T? |
@Nullable T |
| Safe call | ?. |
?. |
N/A |
| Safe indexing | ?[i] |
N/A | N/A |
| Elvis operator | ?: |
?: |
N/A |
| Non-null assertion | !! |
!! |
N/A |
Null Literal Checking (W0012)¶
Assigning the null literal to a non-nullable type warns:
The warning covers declarations, assignments, arguments and returns.
Promote it to an error with --warn error, or suppress it with
--Wno null-to-non-nullable. Values coming from Java APIs are not
checked (their nullness is unknown to the compiler).
Safe Indexing (?[]) and Non-Null Assertion (!!)¶
xs?[i] indexes a nullable receiver: null when xs is null, the element
(widened to nullable) otherwise. Works for arrays, List and maps, and
chains with ?.:
expr!! asserts the value is not null: the static type loses one level
of nullability, and a NullPointerException is thrown at the assertion
site if the value is actually null. Prefer ?./?:/null checks; use
!! when you know better than the type system:
Nullable-Aware Generics¶
Type parameters interact with nullability the same way Kotlin's do.
Bare [T] ranges over nullable types¶
A type parameter declared without a bound accepts both String and
String? as type arguments:
class Box[T] {
val item: T
public:
def this(item: T) { this.item = item }
def get(): T { return this.item }
}
val maybe: String? = null
val box = new Box[String?](maybe) // OK: T := String?
val out: String? = box.get()
Because T may be instantiated with a nullable type, values of type T
cannot be dereferenced directly inside the generic body (E0057):
Use safe navigation, the Elvis operator, or a null check — the same
tools that work on T?:
def safe(): String? { return this.item?.toString() }
def fallback(): String { return (this.item ?: "fb").toString() }
def checked(): String {
val it = this.item
if it != null { return it.toString() } else { return "<null>" }
}
[T extends B] keeps T non-null¶
Declaring a bound restricts the parameter to non-null types: nullable
type arguments are rejected, and values of T can be dereferenced
freely inside the body:
class Sorted[T extends Comparable] {
val item: T
public:
def this(item: T) { this.item = item }
def show(): String { return this.item.toString() } // OK
}
new Sorted[String]("ok") // OK
new Sorted[String?](maybe) // error: String? does not satisfy the bound
This also applies to [T extends Object] — unlike a bare [T], it
rejects nullable arguments.
[T extends B?] opts back into nullable with a bound¶
Inference¶
Type-argument inference binds nullable types when the arguments require it, and merges mixed nullability:
static def first[T](a: T, b: T): T { return a }
first(maybe, "solid") // T := String? (String? + String merge)
Type variables coming from Java classes are platform parameters: their nullability is unknown, so they accept nullable arguments and remain dereferenceable (matching how Java values are treated elsewhere).
Next Steps¶
- Control Flow - if, while, for, select
- Functions - Function definitions and lambdas
- Variables and Types - Type system overview