skip to content

From an API-design and binary-compatibility standpoint, when would you prefer default arguments over explicit overloads, and what are the pitfalls of adding a new defaulted parameter to a published function?

level: principalimportance: nice to knowfreq 28%

answer

  1. Defaults = optional config, same body
  2. Overloads = different types/bodies, stable signatures
  3. Adding defaulted param changes JVM descriptor
  4. Old binaries -> NoSuchMethodError
  5. Source-compatible != binary-compatible

basics

~20 s

Defaults give cleaner, smaller APIs and are great for optional configuration. But adding a defaulted parameter to an already-published function changes its bytecode signature, which can break Java callers and previously compiled binaries even though Kotlin source still compiles.

solid answer

~50 s

Prefer **default arguments** when parameters are genuinely optional configuration and the variations differ only by which values are supplied — they avoid telescoping overloads and read clearly with named arguments. Prefer **explicit overloads** when different argument shapes need genuinely different bodies/types, when you need stable Java-callable signatures, or for performance-sensitive paths. The pitfall: defaults are not a free binary-compatible extension. Adding a parameter (even defaulted) changes the JVM method descriptor, so already-compiled callers linked against the old signature get a NoSuchMethodError at runtime, and Java callers won't see the new default unless you maintain @JvmOverloads. The synthetic $default dispatcher also changes when parameters/order change. For published libraries, treat adding even a defaulted parameter as a potentially breaking change; consider @JvmOverloads, keep parameter order stable, or add a separate overload to preserve the old binary signature.

code

kotlin · 9 lines
kotlin
// Published v1
fun render(text: String) { /* ... */ }

// v2: source-compatible but BINARY-breaking for precompiled callers
fun render(text: String, width: Int = 80) { /* ... */ }

// Safer: keep old signature, add an overload
fun render(text: String) = render(text, 80)
fun render(text: String, width: Int) { /* ... */ }

go deeper

for a junior

Can state defaults reduce overloads but won't address binary compatibility.

for a middle

Picks defaults vs overloads on ergonomics, but may miss the descriptor/runtime-break nuance.

for a senior

Knows defaults change bytecode and that Java needs @JvmOverloads; reasons about API surface.

for a principal

Articulates descriptor-level binary compatibility, NoSuchMethodError risk, versioning policy, and concrete mitigations.

## When to prefer defaults Use **default arguments** when: - Parameters are **optional configuration** (timeouts, flags, formatting) and the body is the same regardless. - You want a **small, discoverable** API instead of a fan of telescoping overloads. - Call sites benefit from **named arguments** (`render(width = 80)`), making intent explicit. ## When to prefer explicit overloads Use **overloads** when: - Different argument shapes require **different return types or bodies** (not just a filled-in value). - You need **stable, Java-friendly signatures** without relying on `@JvmOverloads` generation. - A **hot path** wants a specialized signature avoiding the synthetic dispatcher's bitmask logic. - Overload resolution (different *types*, e.g. `f(Int)` vs `f(String)`) is the actual goal — defaults don't help there. ## The binary-compatibility pitfall This is the principal-level trap. A default argument is **not** the same as a backward-compatible API extension at the **bytecode** level: - The JVM identifies a method by its **descriptor** (name + parameter/return types). Adding a parameter — *even a defaulted one* — produces a **new descriptor**. - Code **already compiled** against the old signature is linked to the old descriptor. At runtime it throws `NoSuchMethodError` because that exact method no longer exists. Recompiling the source against the new version fixes it, but **pre-built binaries break**. - The compiler also emits/changes the synthetic **`$default`** dispatcher; its signature includes a bitmask and changes when you add/reorder parameters. ```kotlin // v1 (published) fun render(text: String) { /* ... */ } // v2 — looks harmless, but it is a BINARY-breaking change fun render(text: String, width: Int = 80) { /* ... */ } // Old compiled callers expected render(String); they now get NoSuchMethodError. ``` ### Java callers Without **`@JvmOverloads`**, Java callers never saw a no-`width` overload; with it, adding a parameter changes which overloads are generated, again altering the published surface. ## Mitigations for libraries - **Keep parameter order stable**; append new defaulted parameters only with care and document the binary impact. - Add a **separate overload** preserving the old exact signature if pre-built binary compatibility matters. - Use **`@JvmOverloads`** deliberately for Java/Android consumers and re-verify generated overloads after changes. - Treat "add a defaulted parameter" as a **minor-but-binary-affecting** change in your versioning policy (source-compatible ≠ binary-compatible). ## Summary Defaults optimize for source ergonomics and a clean API; overloads optimize for explicit signatures and stable bytecode. For *internal* code, defaults are usually the right call. For *published* libraries, remember the descriptor changes and plan for binary compatibility.

  • Why can adding a defaulted parameter compile fine in Kotlin yet break a previously compiled Java/Kotlin caller at runtime?
    The JVM resolves methods by descriptor; adding a parameter changes the descriptor, so old binaries linked to the previous signature hit NoSuchMethodError until recompiled.
  • When are explicit overloads clearly better than defaults?
    When variants need different parameter types, different return types/bodies, or stable Java-callable signatures, or when overload resolution by type is the actual goal.

saying these in an interview costs you the question

  • Claiming adding a defaulted parameter is always binary-compatible
  • Treating source compatibility as equivalent to binary compatibility
  • Recommending defaults to replace type-based overload resolution
  • Ignoring @JvmOverloads impact on published Java surface
  • Assuming the synthetic $default dispatcher never changes

context