Skip to content

Nullability and Return Types

A method’s return type answers two independent questions: can this fail? (bare vs. Result<T>) and can the value be legitimately absent? (non-null vs. nullable). Decide them separately, then combine.

Wrapping in Result<T>:

  • Bare type when the call cannot fail in any way, or when it is an initialization method that is only called during component initialization, where exceptions are allowed (see Errors and Exceptions).
  • Wrapped in every other case, unless explicitly overridden by the author.

Nullability:

  • Nullable T? when the method’s only responsibility is to retrieve a value that may be legitimately absent.
  • Non-nullable when the value must always be present, or when the method’s responsibility is to act on the value before returning it (an updating method, for example). In that case the method returns Result<T> with a non-nullable parameter, capturing NotFound as one of the possible failures.

A bare return is a claim, and the claim must be structurally true

Section titled “A bare return is a claim, and the claim must be structurally true”

“Cannot fail in any way” is not a judgement about how the method is called today; it is a property the type system must guarantee. A method whose body contains an unchecked cast, an array index, or a require can fail, whatever its callers currently do.

The usual way to earn a bare return is to move the invariant into construction — see smart constructors. Once the only way to build an input is through a validating factory, a method over that input genuinely cannot fail, and its bare return becomes legal rather than merely tolerated.

Retrieval, deserialization, and acting on a value are three different things

Section titled “Retrieval, deserialization, and acting on a value are three different things”

The nullable branch above is for retrieval. Two neighbouring cases are routinely misfiled into it:

  • Deserialization is not retrieval. Parsing untrusted wire input — a scope name, an enum case, a date — yields Result<T>, not T?. A string that names nothing is malformed input, not an absent value, and the caller needs the reason. This also keeps a parser on the same error channel as the codecs around it: a payload parsed half through T? and half through Result<T> forces every caller to handle two failure shapes for one wire message.
  • Acting on a value is not retrieval. If the method mutates, persists, or otherwise does something to the value on the way through, absence is a failure of that operation, not a legitimate answer. Return Result<T> carrying AppError.NotFound.
// Pure retrieval, legitimately absent → bare nullable.
fun lookup(key: String): SettingsDefinition<*>? = byKey[key]
// Retrieval where a *different* condition is distinguishable from absence → Result<T?>.
suspend fun childOf(parentId: EntityId): Result<Child?> // failure = parent missing
// success(null) = parent has no child
// Deserialization → Result<T>. "unknown" is malformed input, not absence.
fun fromWire(value: String): Result<SettingsScope>
// Acts on the value → Result<T>, NotFound as a failure.
suspend fun activate(id: EntityId): Result<Subscription>
// Cannot fail — the paired factory guarantees the type — → bare.
fun encode(value: Any?): JsonElement
  1. Null should only be returned from methods that explicitly search or retrieve information. In these cases the method returns Result<T?>:

    • A null payload means the value was not found.
    • A failure means a different condition occurred (e.g., a required parent record was itself missing).

    Example: retrieving a child record from a parent-child relationship — if the parent is null, return failure. If the parent is found but the child is not, return success with a null payload.

  2. In all other cases (e.g., mutating system state), if the target of the operation is null or not found, return Result.failure(...).

Result<T?> or bare T?? Rule 1’s Result<T?> is for retrieval where a second condition is distinguishable from absence — the parent-child example, where a missing parent and a missing child are different answers. When absence is the only thing that can happen and nothing else can go wrong, there is no failure channel to model and a bare T? is correct. Do not wrap a total lookup in a Result that can never be a failure; every caller then pays for a branch that cannot be taken.