skip to content

When designing a library API, would you model an optional asynchronous hook as `(suspend () -> Unit)?` or as a non-null `suspend () -> Unit = {}` default? Discuss the trade-offs.

level: principalimportance: nice to knowfreq 20%

answer

  1. Default `{}` = cleanest call site, no `?.`
  2. Nullable = absence is observable / inspectable
  3. `{}` hides 'unset' vs user no-op
  4. Allocation: cache a shared NONE lambda
  5. Hook runs in your scope → cancellation + exceptions propagate

basics

~20 s

A default empty lambda is usually cleaner for callers because there is no null to check. Nullable is better when 'no hook' is a meaningful state you must detect or when the lambda allocation matters.

solid answer

~50 s

Both express "optional async callback", but they differ in ergonomics and intent. A **non-null default** `suspend () -> Unit = {}` lets call sites invoke it unconditionally (`hook()` inside a suspend context) with no `?.`, no branch, and no risk of forgetting the null check — simplest for the common path. A **nullable** `(suspend () -> Unit)?` makes "absent" a first-class, inspectable state: you can branch on `if (hook != null)` to skip setup work, log, or choose a different code path, and `null` is the natural cross-language/serialization-friendly "unset". Downsides: nullable forces `?.invoke()` everywhere and risks accidental unsafe calls; the default-lambda hides absence and may allocate (mitigable by a shared `EMPTY` constant). I default to the empty-lambda for fire-and-forget hooks, and choose nullable when absence must be observable or drives control flow. Either way the call site still needs a coroutine context.

code

kotlin · 11 lines
kotlin
class Uploader(
    private val onProgress: (suspend (Int) -> Unit)? = null // nullable: lets us skip work
) {
    suspend fun upload(bytes: ByteArray) {
        val report = onProgress
        if (report == null) { /* fast path, no progress tracking */ }
        else report(0)
        // ... upload ...
        report?.invoke(100)
    }
}

go deeper

for a junior

Can state the surface difference: nullable needs ?.invoke(), default lambda is called directly.

for a middle

Lists ergonomics and the 'can you detect absence' distinction with correct invocation rules.

for a senior

Adds allocation considerations and exception/cancellation behavior of invoking the hook in-scope.

for a principal

Frames it as a deliberate API trade-off, recommends per-use-case, and ties to structured concurrency, serialization, and performance.

## The two designs ```kotlin // A) nullable class ClientA(val onRetry: (suspend () -> Unit)? = null) { suspend fun call() { /* ... */ onRetry?.invoke() } } // B) non-null default class ClientB(val onRetry: suspend () -> Unit = {}) { suspend fun call() { /* ... */ onRetry() } } ``` Both let a caller omit the hook. The difference is how "absent" is represented and used. ## Ergonomics at the call site - **Default lambda (B):** invoke directly — `onRetry()` (still needs a suspend context). No `?.`, no branch, impossible to forget the null check. Best for **fire-and-forget** notifications where doing nothing is fine. - **Nullable (A):** every call needs `onRetry?.invoke()`. More noise and a small chance of an accidental non-safe call attempt (which won't compile, so it's caught — but it's friction). ## When absence must be observable Nullable wins when the library must **detect** whether a hook was provided: - skip expensive setup if there's no hook (`if (onRetry == null) return`), - expose it through serialization/config where `null` = unset, - choose different behavior (e.g. fall back to a default policy). A default `{}` is indistinguishable from "a user-supplied no-op", so you lose that signal. ## Allocation / performance - A default `{}` is a lambda; the compiler typically caches a stateless lambda as a singleton, so allocation is usually negligible — but each distinct default site can be its own instance. You can be explicit with a shared constant: `companion object { val NONE: suspend () -> Unit = {} }`. - Nullable avoids any lambda when absent. ## Structured concurrency & errors Regardless of representation, invoking an async hook runs **inside your scope**, so: - exceptions from the hook propagate into your coroutine — decide whether to wrap in `try/catch` or a `supervisorScope`, - cancellation propagates into the hook, - consider whether the hook should run on the caller's dispatcher. ## My default - Fire-and-forget, no need to detect absence → **non-null `= {}`** for the cleanest API. - Absence drives control flow, must be inspectable/serializable, or hot path where allocation matters → **nullable**. This is a design judgement, not a correctness rule — there is no single right answer, only the trade-off articulated above.

  • If you pick the default-lambda design, how do you avoid an allocation per construction?
    Use a shared constant, e.g. `companion object { val NOOP: suspend () -> Unit = {} }` and default the parameter to it; stateless lambdas are also commonly cached by the compiler as singletons.
  • An optional suspend hook throws. What happens to your coroutine?
    The exception propagates up through the invocation into your scope and can cancel it. Wrap in try/catch or run under supervisorScope if a failing hook should not tear down your operation.

A default lambda is a doorbell wired to a silent buzzer (always there, does nothing); nullable is no doorbell at all — you can tell the difference if you look.

saying these in an interview costs you the question

  • Claiming one option is universally correct with no trade-off
  • Ignoring that the hook runs inside your coroutine scope (cancellation/exceptions)
  • Thinking a default `{}` lets you detect whether a hook was supplied
  • Forgetting that both still require a suspend context to invoke
  • Asserting nullable is always faster without considering call-site noise/safety

context