Sealed Classes in Kotlin: A Practical Solution for Finite API Response Hierarchies
Kotlin’s sealed classes let you model API responses as a closed hierarchy, ensuring exhaustive handling at compile time. Learn how to define a Result type, use exhaustive when, and understand the trade‑offs in this practical guide.
15 Apr 2026, 09:10 UTC

Concrete Problem: Handling API Responses Without Nulls or Exceptions
When a client talks to a REST endpoint, the response can be a success payload, a validation error, or an unexpected server fault. Developers often fall back to nullable return types or throw exceptions, both of which obscure the intent and make callers write defensive code. The question is: how can we model these finite outcomes in a way that the compiler guarantees we handle every case?
Thesis: Sealed Classes Provide Compile‑Time Exhaustiveness
Kotlin’s sealed classes restrict inheritance to the file where they’re declared. This closed hierarchy lets the compiler enforce exhaustive when checks, eliminating the need for null checks or sentinel values. For API responses, a sealed class with a small set of subclasses—Success and Failure—offers a clean, type‑safe contract.
1. Defining a Finite Result Hierarchy
Below is a minimal sealed class that represents the two possible outcomes of an API call. Note that the subclasses are declared in the same file, so no external code can add new variants.
// Result.kt
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Failure(val error: Throwable) : Result<Nothing>()
}
The generic T allows Success to carry any payload type, while Failure holds the exception that caused the error. Because Failure extends Result<Nothing>, it cannot be cast back to Result<T>, preserving type safety.
2. Using Exhaustive 'when' to Handle All Cases
When you write a when expression over a Result, the compiler requires you to cover every subclass. If you forget one, you’ll see a compile‑time error, forcing you to address it immediately.
fun handleResponse(response: Result<T>) {
when (response) {
is Result.Success -> processData(response.data)
is Result.Failure -> logError(response.error)
}
}
Because the sealed hierarchy is closed, there’s no need for an else branch. The compiler guarantees that the when is exhaustive. If you later add a new subclass, the compiler will flag all existing when usages that no longer cover the new case.
3. Factory Methods for Clean Construction
To keep API call sites tidy, provide a companion object with a factory that hides the subclass details.
object Api {
suspend fun <T> fetch(url: String, mapper: (String) -> T): Result<T> {
return try {
val raw = httpGet(url)
Result.Success(mapper(raw))
} catch (e: Exception) {
Result.Failure(e)
}
}
}
Callers now simply write:
val userResult = Api.fetch("/user/42") { json -> json.fromJson(User::class) }
handleResponse(userResult)
4. Trade‑Offs and Limitations
- Scope Restriction: Sealed classes can only be extended within the file they’re declared. Attempting to add a new subclass in another module causes a compilation error. This is great for stability but can hinder flexibility when external libraries need to introduce new variants.
- Hierarchy Size: For very large or evolving response types, a sealed class can become unwieldy. In such cases, a generic
Eitheror a library‑providedResulttype may be preferable. - Interoperability: Some Java libraries expect nullable types or throw checked exceptions. When integrating, you may need adapters that convert
Resultto the expected form.
5. Practical Checklist
- Define the sealed
Resulthierarchy in a single Kotlin file. - Implement factory methods that return the sealed type.
- Use exhaustive
whenexpressions for handling responses. - Run a unit test that passes both
SuccessandFailuretohandleResponseand confirm no compiler warnings appear. - Try to create a new subclass in a different file; the build should fail, confirming the closed hierarchy.
Actionable Closing
Sealed classes give you a small, safe, and compiler‑checked way to model finite API response sets. By eliminating nulls and exceptions from the surface, you make your code easier to read, test, and maintain. Start by refactoring one of your service layers to use a sealed Result type, and observe how the compiler nudges you toward exhaustive handling. If your project grows beyond a handful of variants, consider switching to a more flexible generic result type, but keep the sealed‑class pattern for tightly scoped, well‑defined cases.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.