skip to content

Design a companion factory that validates input and can fail gracefully and/or return different subtypes. Compare returning null, Result, throwing, and sealed-result types.

level: seniorimportance: should knowfreq 30%

answer

  1. return type drives failure & polymorphism
  2. T? = absent, Result/sealed = failure-with-reason
  3. require/check = invariant violation → throw
  4. supertype/sealed return → factory picks subtype
  5. name failable variants: ...OrNull / tryParse

basics

~20 s

A factory can check the input before building. If the input is bad it can return null, return a Result with the error, or throw. It can also decide which subtype to create based on the input and return them all as the common supertype.

solid answer

~40 s

Because a factory is an ordinary function with a chosen return type, you control the failure and polymorphism story. For failure: return `T?` (`parseOrNull`) for simple optional construction; return `Result<T>` (`runCatching`) or a custom sealed type when callers need the error reason; throw `IllegalArgumentException` via `require` for programmer-error preconditions that should never occur at runtime. For polymorphism: declare the return type as a sealed supertype or interface and pick the concrete subtype inside the factory (`when (input) { ... }`), returning each as the supertype — the private constructors of the subtypes keep the factory the sole creator. Convention: name failable variants explicitly (`fromStringOrNull`, `tryParse`) so the contract is visible at the call site. Prefer null for 'absent', Result/sealed for 'failed with reason', and exceptions for true precondition violations.

code

kotlin · 16 lines
kotlin
sealed interface Money
private class Cash(val amt: Long) : Money
private class Card(val token: String) : Money

object MoneyFactory {
    fun parse(s: String): Result<Money> = runCatching {
        when {
            s.startsWith("$") -> Cash(s.drop(1).toLong())
            s.startsWith("card:") -> Card(s.removePrefix("card:"))
            else -> throw IllegalArgumentException("unknown money: $s")
        }
    }
}

val ok = MoneyFactory.parse("$500")          // Result.success(Cash)
val bad = MoneyFactory.parse("???")          // Result.failure(...)

go deeper

for a junior

Knows a factory can validate and return null if input is bad.

for a middle

Distinguishes nullable vs throwing and can write a require-based validating factory.

for a senior

Maps each failure mode (null/Result/sealed/throw) to its use case and returns a sealed supertype with internal subtype dispatch.

for a principal

Defines a consistent error-handling policy across the API surface, balancing exhaustiveness, ergonomics, and binary-compatible evolution of return types.

## Why the factory owns this decision A factory's **declared return type** is free to be nullable, a `Result`, a sealed class, or a supertype — a constructor cannot do any of these. This makes the companion factory the natural home for validation, graceful failure, and polymorphic dispatch. ## Failure strategies ```kotlin class Email private constructor(val value: String) { companion object { // 1) nullable: 'absent/invalid' with no reason fun parseOrNull(s: String): Email? = if ("@" in s) Email(s) else null // 2) Result: failure WITH a reason fun parse(s: String): Result<Email> = if ("@" in s) Result.success(Email(s)) else Result.failure(IllegalArgumentException("missing @")) // 3) throw: precondition that must hold fun of(s: String): Email { require("@" in s) { "invalid email: $s" } return Email(s) } } } ``` - **`T?` (nullable)** — cheapest; use when callers only need 'present or not' and Elvis/`?.` handling suffices. - **`Result<T>`** — carries the failure; `runCatching { ... }` or explicit `Result.success/failure`. Caller uses `getOrElse`, `fold`, `onFailure`. - **Throwing (`require`/`check`)** — for **programmer errors / invariants** that indicate a bug, not expected user input. - **Custom sealed result** — when you have several distinct, exhaustively-handled outcomes: ```kotlin sealed interface ParseResult { data class Ok(val email: Email) : ParseResult data object MissingAt : ParseResult data object Empty : ParseResult } ``` A `when` over a sealed type is exhaustive, so callers can't forget a case. ## Polymorphic / subtype-returning factories The return type can be a sealed supertype; the factory chooses the concrete subtype: ```kotlin sealed class Shape { private class Circle(val r: Double) : Shape() private class Square(val s: Double) : Shape() companion object { fun of(kind: String, v: Double): Shape = when (kind) { "circle" -> Circle(v) "square" -> Square(v) else -> error("unknown $kind") } } } ``` Callers receive `Shape` and never see the concrete classes — a constructor could never do this. Private subtype constructors keep the companion the sole creator (a flavor of the **factory-method** pattern). ## Choosing - Optional, reason not needed → **nullable**. - Failure with a reason, recoverable → **Result** or **sealed result**. - Must-not-happen invariant → **throw** via `require`. - Need to vary the concrete type → **supertype/sealed return** with internal dispatch. Name failable variants (`...OrNull`, `tryParse`, `parse`) so the contract is obvious.

  • When is throwing preferable to returning a Result?
    When the bad input represents a programmer error or broken invariant (a bug), not an expected runtime condition — use require/check so the failure surfaces loudly during development.
  • How does returning a sealed supertype help callers?
    A `when` over a sealed type is exhaustive, so the compiler forces callers to handle every outcome/subtype, preventing forgotten cases.

saying these in an interview costs you the question

  • Throwing for ordinary, expected invalid user input
  • Returning null when the caller clearly needs the failure reason
  • Exposing concrete subtype constructors, defeating the factory
  • Swallowing exceptions in runCatching without surfacing them
  • Mixing several failure styles inconsistently across one API

context