Sealed Class Result Error Handling
Build a service layer that models expected failures (not found, validation,
conflict, unauthorized) as data instead of throwing exceptions. A custom
sealed Result<out T> type carries either a success value or a typed AppError,
and the compiler forces every caller to handle both — no silently-swallowed
errors, no stack traces for things you expected.
What you’ll build
Section titled “What you’ll build”A framework-free task API split into the layers you’d recognize from any backend:
- An
AppErrorsealed hierarchy with the variantsNotFound,Validation,Conflict,Unauthorized, andInternal. - A custom
Result<out T>sealed class withSuccessandFailure, plusmap,flatMap,getOrElse,onSuccess, andonFailure. - A
TaskServicewhose methods returnResult<Task>instead of throwing. - A
TaskControllerthat uses an exhaustivewhento map each error to an HTTP-like status code. - Tests covering both the success and failure paths.
If you’ve used Go’s (T, error) return pattern, TypeScript’s
neverthrow Result<T, E>, or
Rust’s Result<T, E>, this is the same idea — but Kotlin’s sealed class plus
exhaustive when gives you compiler-enforced handling of every error variant
for free.
The worked solution
Section titled “The worked solution”A single Gradle module, organized by layer. The two files under error/ are the
core of the exercise; the rest is a small domain to exercise them against.
Directorysealed-result-errors/
- build.gradle.kts deps + build config
- settings.gradle.kts project name
Directorysrc/main/kotlin/com/example/taskapi/
Directoryerror/
- AppError.kt sealed error hierarchy
- Result.kt custom
Result<out T>type
Directorymodel/
- Task.kt domain model
Directoryrepository/
- TaskRepository.kt in-memory storage
Directoryservice/
- TaskService.kt business logic returning
Result<Task>
- TaskService.kt business logic returning
Directorycontroller/
- TaskController.kt maps
Resultto HTTP-like responses
- TaskController.kt maps
- Main.kt demo entry point
Directorysrc/test/kotlin/com/example/taskapi/
- ResultTest.kt tests for the
Resulttype - TaskServiceTest.kt tests for the service layer
- ResultTest.kt tests for the
The error hierarchy
Section titled “The error hierarchy”Each failure mode is its own data class, so it can carry exactly the data that
matters for that case — NotFound knows the resource and id, Validation knows
the field. Because AppError is sealed, the compiler knows the complete set of
subtypes, which is what makes the when in the controller exhaustive.
package com.example.taskapi.error
/** * Sealed class hierarchy for domain errors. * * Instead of throwing exceptions for expected failures (not found, validation, conflict), * we model them as data. The compiler forces callers to handle every case. * * Compare to: * - Go: error interface, fmt.Errorf, sentinel errors * - TypeScript: neverthrow Result, custom Error subclasses */sealed class AppError { data class NotFound(val resource: String, val id: String) : AppError() data class Validation(val field: String, val message: String) : AppError() data class Conflict(val message: String) : AppError() data class Unauthorized(val reason: String) : AppError() data class Internal(val cause: Throwable) : AppError()}The Result type
Section titled “The Result type”Result<out T> is the heart of the exercise. Two notes on the variance:
- The class is declared
Result<out T>(covariant), so aResult<Cat>is usable where aResult<Animal>is expected. Failurecarries no success value, so it extendsResult<Nothing>. SinceNothingis a subtype of everything, a singleFailureinstance is assignable to anyResult<T>— which is whymapandflatMapcan returnthisunchanged on the failure path.
The operations are pure when expressions over the two variants. map<R>
transforms the success value; flatMap<R> chains another fallible operation
(returning its Result directly); getOrElse supplies a fallback. The
@UnsafeVariance on getOrElse lets T appear in an in position despite the
covariant declaration — a deliberate, safe escape hatch.
package com.example.taskapi.error
/** * A custom Result type that models success or failure explicitly. * * This is similar to: * - Go's (T, error) return pattern * - TypeScript's neverthrow Result<T, E> * - Rust's Result<T, E> */sealed class Result<out T> { data class Success<T>(val value: T) : Result<T>() data class Failure(val error: AppError) : Result<Nothing>()
/** * Transform the success value. * If this is a Failure, return it unchanged. */ fun <R> map(transform: (T) -> R): Result<R> = when (this) { is Success -> Success(transform(value)) is Failure -> this }
/** * Chain operations that may also fail. * If this is a Failure, return it unchanged. */ fun <R> flatMap(transform: (T) -> Result<R>): Result<R> = when (this) { is Success -> transform(value) is Failure -> this }
/** * Get the value or null. */ fun getOrNull(): T? = when (this) { is Success -> value is Failure -> null }
/** * Get the value or a default. */ fun getOrElse(default: () -> @UnsafeVariance T): T = when (this) { is Success -> value is Failure -> default() }
/** * Perform an action on success. */ fun onSuccess(action: (T) -> Unit): Result<T> { if (this is Success) action(value) return this }
/** * Perform an action on failure. */ fun onFailure(action: (AppError) -> Unit): Result<T> { if (this is Failure) action(error) return this }
val isSuccess: Boolean get() = this is Success val isFailure: Boolean get() = this is Failure}The domain: model and repository
Section titled “The domain: model and repository”A plain data class and an in-memory map. Nothing here returns a Result — the
repository deals in nullables (findById returns Task?), and it’s the
service’s job to turn a null into a typed AppError.
package com.example.taskapi.model
import java.util.UUID
data class Task( val id: String = UUID.randomUUID().toString(), val title: String, val description: String = "", val assignedTo: String? = null, val completed: Boolean = false)package com.example.taskapi.repository
import com.example.taskapi.model.Task
class TaskRepository { private val tasks = mutableMapOf<String, Task>()
fun save(task: Task): Task { tasks[task.id] = task return task }
fun findById(id: String): Task? = tasks[id]
fun findAll(): List<Task> = tasks.values.toList()
fun delete(id: String): Boolean = tasks.remove(id) != null
fun existsByTitle(title: String): Boolean = tasks.values.any { it.title == title }}The service: failures as return values
Section titled “The service: failures as return values”Every method’s signature — Result<Task> — is the contract. The validation,
conflict, and not-found checks each return a Result.Failure wrapping the
appropriate AppError variant; the happy path returns Result.Success. Note how
findById(...) ?: return Result.Failure(...) uses Kotlin’s Elvis operator to
turn the repository’s nullable into an early-return failure — the equivalent of
Go’s if t == nil { return nil, ErrNotFound }.
package com.example.taskapi.service
import com.example.taskapi.error.AppErrorimport com.example.taskapi.error.Resultimport com.example.taskapi.model.Taskimport com.example.taskapi.repository.TaskRepository
/** * Service layer that returns Result<T> instead of throwing exceptions. * * Every method explicitly declares its failure modes through the return type. * The compiler forces callers to handle both success and failure. */class TaskService(private val repo: TaskRepository) {
fun createTask(title: String, description: String, assignedTo: String? = null): Result<Task> { // Validate title if (title.isBlank()) { return Result.Failure(AppError.Validation("title", "Title is required")) } if (title.length > 200) { return Result.Failure(AppError.Validation("title", "Title must be under 200 characters")) }
// Check for duplicate titles if (repo.existsByTitle(title)) { return Result.Failure(AppError.Conflict("A task with title '$title' already exists")) }
val task = repo.save(Task(title = title, description = description, assignedTo = assignedTo)) return Result.Success(task) }
fun getTask(id: String): Result<Task> { val task = repo.findById(id) ?: return Result.Failure(AppError.NotFound("Task", id)) return Result.Success(task) }
fun completeTask(taskId: String, userId: String): Result<Task> { val task = repo.findById(taskId) ?: return Result.Failure(AppError.NotFound("Task", taskId))
if (task.assignedTo != null && task.assignedTo != userId) { return Result.Failure(AppError.Unauthorized("Only the assignee can complete this task")) }
if (task.completed) { return Result.Failure(AppError.Conflict("Task is already completed")) }
val updated = repo.save(task.copy(completed = true)) return Result.Success(updated) }
fun deleteTask(id: String): Result<Unit> { if (repo.findById(id) == null) { return Result.Failure(AppError.NotFound("Task", id)) } repo.delete(id) return Result.Success(Unit) }}The controller: exhaustive mapping to HTTP
Section titled “The controller: exhaustive mapping to HTTP”Each endpoint unwraps the service’s Result with when (val result = ...),
returning a success status on Result.Success and delegating to
error.toResponse() on Result.Failure.
The payoff is the AppError.toResponse() extension function at the bottom.
Because AppError is sealed and used as a when expression (its result is
returned), the when is exhaustive — there is no else branch. Add a sixth
AppError variant tomorrow and this file stops compiling until you handle it.
That’s the whole point: the compiler keeps your error-to-status mapping complete.
package com.example.taskapi.controller
import com.example.taskapi.error.AppErrorimport com.example.taskapi.error.Resultimport com.example.taskapi.service.TaskService
/** * Simulated HTTP controller that maps Result<T> to response codes. * * In a real Spring Boot app, this would return ResponseEntity<Any>. * Here we simulate it with a simple Response data class to keep * this exercise framework-free. */data class Response(val status: Int, val body: Any?)
class TaskController(private val taskService: TaskService) {
fun createTask(title: String, description: String, assignedTo: String? = null): Response { return when (val result = taskService.createTask(title, description, assignedTo)) { is Result.Success -> Response(201, result.value) is Result.Failure -> result.error.toResponse() } }
fun getTask(id: String): Response { return when (val result = taskService.getTask(id)) { is Result.Success -> Response(200, result.value) is Result.Failure -> result.error.toResponse() } }
fun completeTask(taskId: String, userId: String): Response { return when (val result = taskService.completeTask(taskId, userId)) { is Result.Success -> Response(200, result.value) is Result.Failure -> result.error.toResponse() } }
fun deleteTask(id: String): Response { return when (val result = taskService.deleteTask(id)) { is Result.Success -> Response(204, null) is Result.Failure -> result.error.toResponse() } }}
/** * Extension function that maps each AppError variant to an HTTP-like response. * The exhaustive `when` ensures every error type is handled. */fun AppError.toResponse(): Response = when (this) { is AppError.NotFound -> Response(404, mapOf("error" to "NOT_FOUND", "message" to "$resource with id $id not found")) is AppError.Validation -> Response(400, mapOf("error" to "VALIDATION_ERROR", "message" to "$field: $message")) is AppError.Conflict -> Response(409, mapOf("error" to "CONFLICT", "message" to message)) is AppError.Unauthorized -> Response(403, mapOf("error" to "UNAUTHORIZED", "message" to reason)) is AppError.Internal -> Response(500, mapOf("error" to "INTERNAL_ERROR", "message" to "An internal error occurred"))}Tying it together
Section titled “Tying it together”Main.kt wires repository → service → controller and walks through one success
and several failure paths, printing the status and body each time — a quick way
to see the whole pipeline in action.
package com.example.taskapi
import com.example.taskapi.controller.TaskControllerimport com.example.taskapi.repository.TaskRepositoryimport com.example.taskapi.service.TaskService
fun main() { println("=== Sealed Result Error Handling Demo ===\n")
val repo = TaskRepository() val service = TaskService(repo) val controller = TaskController(service)
// Success: Create a task println("--- Create task ---") val createResponse = controller.createTask("Learn sealed classes", "Kotlin error handling patterns", "user-1") println("Status: ${createResponse.status}, Body: ${createResponse.body}")
// Success: Get a task val taskId = (createResponse.body as? com.example.taskapi.model.Task)?.id ?: "" println("\n--- Get task ---") val getResponse = controller.getTask(taskId) println("Status: ${getResponse.status}, Body: ${getResponse.body}")
// Failure: Validation error (blank title) println("\n--- Create task with blank title ---") val validationResponse = controller.createTask("", "No title") println("Status: ${validationResponse.status}, Body: ${validationResponse.body}")
// Failure: Conflict (duplicate title) println("\n--- Create task with duplicate title ---") val conflictResponse = controller.createTask("Learn sealed classes", "Duplicate") println("Status: ${conflictResponse.status}, Body: ${conflictResponse.body}")
// Failure: Not found println("\n--- Get non-existent task ---") val notFoundResponse = controller.getTask("non-existent-id") println("Status: ${notFoundResponse.status}, Body: ${notFoundResponse.body}")
// Failure: Unauthorized (wrong user trying to complete) println("\n--- Complete task as wrong user ---") val unauthorizedResponse = controller.completeTask(taskId, "user-2") println("Status: ${unauthorizedResponse.status}, Body: ${unauthorizedResponse.body}")
// Success: Complete task as correct user println("\n--- Complete task as assigned user ---") val completeResponse = controller.completeTask(taskId, "user-1") println("Status: ${completeResponse.status}, Body: ${completeResponse.body}")
// Failure: Already completed println("\n--- Complete already-completed task ---") val alreadyCompletedResponse = controller.completeTask(taskId, "user-1") println("Status: ${alreadyCompletedResponse.status}, Body: ${alreadyCompletedResponse.body}")
println("\n=== Demo complete ===")}Run and test
Section titled “Run and test”-
Run the demo to watch each success and failure flow through the layers:
Terminal window ./gradlew run -
Run the test suite, which asserts both the success and failure paths of the service:
Terminal window ./gradlew test
The service tests read naturally because of Kotlin’s backtick test names and the
isSuccess/isFailure helpers — for example, asserting that a blank title
yields an AppError.Validation:
@Testfun `createTask fails with blank title`() { val service = createService() val result = service.createTask("", "Description")
assertTrue(result.isFailure) val error = (result as Result.Failure).error assertTrue(error is AppError.Validation) assertEquals("title", (error as AppError.Validation).field)}