Recommended resources
The freely available resources this lesson will direct you to.
Hello! Last lesson, mapNotNull let you discard values that could not be parsed. That is useful when dropping invalid entries is intentional, but it can hide a problem when every entry matters.
This lesson asks a more fundamental question: how should a function tell its caller that it could not produce the expected value? You’ll use nullable results for an ordinary absence, exceptions for failures that interrupt normal work, and Kotlin’s Result when the caller should receive either a value or an exception to handle later. These choices matter at backend boundaries, where silently treating a failed operation as “no data” can produce the wrong behavior.
When “no value” is a normal answer
Suppose a message service accepts an optional setting for how many delivery attempts to make. A string might contain a valid non-negative number, or it might not:
fun parseAttemptCount(raw: String): Int? {
val count = raw.toIntOrNull() ?: return null
return if (count >= 0) count else null
}
println(parseAttemptCount("3")) // 3
println(parseAttemptCount("unknown")) // null
println(parseAttemptCount("-2")) // null
The return type Int? is part of the function’s contract: callers must account for the possibility that parsing yields no count. Unlike the previous lesson’s mapNotNull, this function does not quietly remove an invalid input from a collection. It gives the caller the decision:
fun describeAttemptCount(raw: String): String {
val count = parseAttemptCount(raw)
?: return "A non-negative attempt count is required"
return "Delivery attempts: $count"
}
Here the Elvis operator, ?:, takes the right-hand path only when parsing returns null. Notice that 0 is a valid result; it does not trigger the fallback. The same nullable-result pattern works for an unsuccessful lookup:
fun findRecipient(
id: String,
recipients: Map<String, String>
): String? = recipients[id]
A missing map key is often an expected possibility. By contrast, null would be a poor return value if callers need to distinguish “recipient not found” from “storage could not be read.” One nullable value cannot explain which of those happened.
Kotlin’s Null safety documentation is a useful short refresher on the Elvis operator, particularly because its right-hand side can return or throw.
Null safety | Kotlin Documentation
Read the “Elvis operator” subsection to connect nullable results with an explicit caller decision.
In “Elvis operator,” begin at the explanation starting “When working with nullable types, you can check for” and read the Elvis explanation through the sentence introducing function-argument checks. Focus on when the fallback is evaluated and why return can appear on the right.
A nullable result is concise when the caller only needs to know whether a value exists. If the reason for failure matters, preserve more information instead of repeatedly guessing what null means.
When an operation cannot continue: exceptions
Consider a function that constructs a message. A blank recipient violates its input requirement; accepting it would allow an invalid message into later processing. Kotlin’s require states that contract at the point where it matters. The example assumes the Message and Channel types used in earlier lessons:
fun createMessage(recipient: String, body: String): Message {
require(recipient.isNotBlank()) { "Recipient must not be blank" }
require(body.isNotBlank()) { "Body must not be blank" }
return Message(recipient, body, Channel.EMAIL)
}
If either requirement fails, require throws an IllegalArgumentException. Use check instead when a function’s current state, rather than an argument, makes proceeding invalid: for example, a sender asked to send before it has been initialized throws an IllegalStateException. You can also throw an exception yourself when you need a particular type, message, or original cause.
An exception changes control flow. The function does not return its declared value; execution moves to a matching catch somewhere up the call stack. Kotlin does not require callers to catch exceptions, so an API’s exception behavior deserves attention even though the compiler does not enforce handling it.
Read the relevant parts of Kotlin’s Exceptions documentation before looking at a recovery example.
Exceptions | Kotlin Documentation
Read the official guide for throwing, catching, and cleaning up after exceptions. Concentrate on what each construct promises a caller, rather than memorizing every example.
In “Throw exceptions,” continue into “Throw exceptions with precondition functions,” including “require() function” and the opening explanation of “check() function.” Read throwing and preconditions; compare invalid input with invalid state. Then, in “Handle exceptions using try-catch blocks,” read from its opening explanation through “The finally block,” stopping after the paragraph explaining which branch supplies a try expression’s value. Follow catch and finally, paying particular attention to specific exception types and the fact that finally runs regardless of the outcome.
A catch should handle a failure it can actually respond to. For example, if an optional draft file does not exist, starting with an empty draft may be legitimate:
fun readOptionalDraft(path: String): String =
try {
java.io.File(path).readText()
} catch (e: java.io.FileNotFoundException) {
""
}
The try and catch branches both produce a String, so the entire try is an expression of that type. This code catches only a missing file. Another I/O failure, such as an inability to read an existing file, still propagates rather than being mislabeled as an empty draft.
For a required file, an empty fallback would conceal a serious problem. Instead, you might add context while retaining the original cause:
fun readRequiredTemplate(path: String): String =
try {
java.io.File(path).readText()
} catch (e: java.io.IOException) {
throw IllegalStateException(
"Could not load required template: $path",
e
)
}
The caller now learns what the application was trying to do, while e remains available for diagnosis. When you acquire a resource that must be closed, finally runs cleanup whether the work succeeds or throws. On the JVM, prefer a resource’s use function when applicable; it handles closing for you.
Carrying success or failure as a Result
A direct exception interrupts the current path. Sometimes a function should instead return an outcome so its caller can decide what to do with the failure. Kotlin’s standard-library Result<T> holds either a successful T or a Throwable. runCatching executes a block and creates that result:
fun loadTemplate(path: String): Result<String> =
runCatching {
java.io.File(path).readText()
}
The caller can handle both cases without an enclosing try:
val status: String = loadTemplate("template.txt").fold(
onSuccess = { text -> "Loaded ${text.length} characters" },
onFailure = { cause -> "Could not load template: ${cause.message}" }
)
fold requires a decision for either outcome and produces one value. Other useful operations include getOrNull() when deliberately discarding failure details, exceptionOrNull() when inspecting the failure, and getOrThrow() when choosing to propagate it. The Result return type makes the two possible outcomes visible to callers, but it does not tell them which exception types can occur.
Watch Dave Leeds’s Try-Catch vs. runCatching() for a comparison of the two approaches and an important limit of runCatching.
Dave Leeds compares immediate exception handling with returning a Result. Watch for the difference between making a failure decision near the operation and deferring that decision to a caller.
Watch try as expression for Kotlin’s try behavior, then Result basics for how runCatching packages an outcome. Skip ahead to its limits, especially which failures it catches, and finish with choosing between them.
One caution governs that choice: runCatching catches every Throwable thrown inside its block, not just the exception you expected. Keep the block narrow, and do not use it as a blanket replacement for a catch targeting a known, recoverable error. Nor does wrapping a failure in Result resolve it—the caller still needs a policy for success and failure.
Choose the contract before the syntax
The same problem can tempt you toward all three mechanisms. Asking what the caller needs usually settles the choice:
| Situation | Useful contract | Why |
|---|---|---|
| A lookup may ordinarily find nothing | Message? |
Only presence or absence matters. |
| An invalid argument or broken state prevents the operation | Throw an exception | Continuing would violate the function’s contract. |
| A caller should receive either a value or a captured exception | Result<Message> |
The caller can inspect or handle the outcome later. |
These are guidelines, not a rule that every invalid input must throw. For instance, toIntOrNull() is well suited to exploratory parsing, while require suits a function whose caller has promised a valid argument. If several expected failure reasons must be distinguished—such as “unknown recipient” and “delivery disabled”—a dedicated sealed outcome type can describe them more precisely than either null or Kotlin’s Result<T>, whose failure side is a Throwable.
The most important design mistake is to erase a distinction the caller needs. Returning null after an I/O exception makes an unavailable service look like missing data. Catching every exception and supplying a default can make a broken operation look successful. Conversely, throwing for every routine “not found” result can make ordinary control flow harder to follow.
Key takeaways
Use a nullable return type when absence is expected and its reason does not matter. Throw or specifically catch exceptions when an operation cannot proceed and recovery or added context is needed. Use Result<T> to return a success value or a captured exception for a caller to handle, while remembering that runCatching catches broadly.
In the next lesson, you’ll write tests for these contracts: not just the successful result, but the missing value, rejected input, and failure outcome that callers rely on.