skip to content

Why is Result<T> discouraged as a public function return type, and what alternatives are recommended?

level: seniorimportance: should knowfreq 40%

answer

  1. Failure side is just Throwable — untyped, undocumented
  2. Value class boxes as generic arg / in collections / nullable
  3. Designers: capture locally, don't return it
  4. Alternatives: throw, nullable, domain sealed type
  5. OK internally / at boundaries, then map immediately

basics

~20 s

Result is meant for internal capture, not as a return type. It is opaque about which errors can occur, it boxes when used generically, and the team that designed it advises returning your own success/failure type or throwing instead.

solid answer

~50 s

Kotlin's design intentionally makes `Result` awkward as a return type: originally you could not even write a function returning `Result<T>` without an opt-in, and the guidance from the stdlib designers is that `Result` is for capturing failures locally, not for modelling a function's contract. Problems: it is an untyped failure channel (`Throwable`, so callers can't see which errors are expected); it hides the error set instead of documenting it; as a value class it gets autoboxed when used as a generic type argument, in collections, or behind interfaces, losing the inline benefit; and it encourages swallowing errors. Recommended alternatives: throw exceptions for truly exceptional cases; for expected, recoverable outcomes model a domain-specific `sealed interface`/`sealed class` (e.g., `Outcome.Success`/`Outcome.Invalid`) or a nullable return; reserve `runCatching`/`Result` for adapting a throwing API at a boundary, then immediately map it.

code

kotlin · 14 lines
kotlin
// Discouraged: opaque error channel + boxes in the List
fun loadAll(): List<Result<User>> = ids.map { runCatching { load(it) } }

// Better: explicit domain outcome, no Throwable leak
sealed interface Loaded {
    data class Ok(val user: User) : Loaded
    data class Missing(val id: Long) : Loaded
}
fun loadAll2(): List<Loaded> = ids.map {
    runCatching { load(it) }.fold(
        onSuccess = { Loaded.Ok(it) },
        onFailure = { _ -> Loaded.Missing(it) }
    )
}

go deeper

for a junior

Can state that Result is usually for internal use, not public signatures.

for a middle

Lists the opaque-Throwable and swallowing problems and knows to map Result before returning.

for a senior

Explains the boxing behaviour of value classes and proposes sealed-type/nullable/throw alternatives with trade-offs.

for a principal

Sets an API convention across the codebase, balancing interop, observability, and exhaustiveness, and knows the KEEP rationale.

## The official stance The Kotlin team designed `Result` primarily for the *suspend/async machinery* and for *local* failure capture. The KEEP design notes and stdlib docs discourage using it as a function return type. Historically the compiler even refused functions whose return type was `Result<T>` unless you opted in, signalling 'this is not the intended use.' ## Why it's a poor return type ### 1. Untyped, undocumented error channel The failure side is just `Throwable`. A signature `fun load(): Result<User>` tells the caller nothing about *which* failures are expected (not found? unauthorized? timeout?). Compare with a sealed type that enumerates outcomes. ### 2. Boxing defeats the value class `Result` is a `@JvmInline value class`. It stays unboxed only in limited positions. The moment it appears as a **generic type argument** (`List<Result<T>>`, `Flow<Result<T>>`), behind a generic interface, or is nullable, the compiler **autoboxes** it into a real heap object — so the 'inline' win is gone and you pay an allocation anyway. ```kotlin fun load(): Result<User> = ... // boxes at the call boundary val many: List<Result<User>> = ... // every element boxed ``` ### 3. Encourages error swallowing A `Result` return invites callers to `getOrNull()` and silently drop the exception, losing context. ## Recommended alternatives - **Throw** for genuinely exceptional, non-recoverable conditions — idiomatic Kotlin, with `@Throws` if Java interop matters. - **Nullable return** when 'absent / failed' needs no detail: `fun find(id: Id): User?`. - **Domain sealed type** when callers must branch on *which* outcome occurred: ```kotlin sealed interface SaveResult { data class Saved(val id: Long) : SaveResult data class Rejected(val reason: String) : SaveResult data object Conflict : SaveResult } ``` This is exhaustive in a `when`, self-documenting, and not tied to `Throwable`. - **Use `runCatching`/`Result` internally**, then fold it into one of the above before returning: ```kotlin fun parsePort(raw: String): Int? = raw.runCatching { toInt() }.getOrNull()?.takeIf { it in 1..65535 } ``` ## When Result is fine Local capture, bridging a throwing API, batch processing where you want to keep going past failures, or internal helpers — anywhere the `Result` does not escape into a public contract.

  • Concretely, when does a Result<T> get boxed?
    When used as a generic type argument (List<Result<T>>, Flow<Result<T>>), behind a generic interface, as a nullable Result<T>?, or stored where an Any/Object reference is expected — any position the inline class can't stay unboxed.
  • How does a sealed result type beat Result for callers?
    It enumerates the exact outcomes, makes when exhaustive, carries typed payload per case, and doesn't force callers to reason about arbitrary Throwables.

saying these in an interview costs you the question

  • Insisting Result is the idiomatic return type for all fallible Kotlin functions
  • Claiming value classes never box, so Result<T> in a List is free
  • Not knowing the stdlib designers discourage it as a return type
  • Treating Result and a domain sealed type as interchangeable with no trade-offs
  • Returning Result just to avoid declaring/throwing exceptions

context