Story Opening

Kabir answered the pricing team’s message in the thread, and pasted his function underneath:

// Fragment of story/FileBackedRepository.kt
class FileBackedRepository(private val path: Path) {
// Throws NoSuchFileException (an IOException) when the export is missing. Nothing in the signature says so.
fun load(): List<String> = Files.readAllLines(path)

Kabir: It throws. Files.readAllLines throws NoSuchFileException, and Kotlin just lets it through. Kotlin has no checked exceptions, so nothing went into the bytecode’s throws clause, and javac thinks it can’t happen.

Pricing lead: So we catch Exception and check the type? In 2026?

Lena: Or he adds one annotation. And then he explains which of your failures should be exceptions at all.

Kabir added the annotation in a minute. The explanation took the rest of the afternoon.


Java → Kotlin: The Quick Map

JavaKotlinNote
List<String> load() throws IOExceptionfun load(): List<String>No checked exceptions; @Throws(IOException::class) for Java callers
try/catch statement assigning a variableval x = try { … } catch (…) { … }try is an expression
Preconditions.checkArgument, checkStaterequire(cond) { msg }, check(cond) { msg }, error(msg)IllegalArgumentException / IllegalStateException
Objects.requireNonNull(x, msg)requireNotNull(x) { msg }Throws IllegalArgumentException, not NPE
try-with-resourcesresource.use { … }An extension on AutoCloseable
Vavr Try<T>Result<T> with runCatching { }Error type fixed to Throwable; unlike Try, rethrows nothing fatal
Vavr Either<L, R>A sealed Outcome<T, E> of your ownTyped errors
Lombok @SneakyThrowsThe defaultEvery exception is unchecked
Checked exception for a business outcomeSealed error typeExhaustive when instead of catch
Error Prone @CheckReturnValue@MustUseReturnValuesExperimental, -Xreturn-value-checker
@NotNull on a field, getter or parameterOne declaration, several targets: @field:, @get:, @all:Defaults changed in 2.4.0
Class<T>, Field, MethodKClass<T>, KProperty, KFunctionFull introspection needs kotlin-reflect

Conceptual Deep-Dive

Why Kotlin dropped checked exceptions

Most Java developers have a private opinion of checked exceptions, formed by years of catch (IOException e) { throw new UncheckedIOException(e); }. Kotlin’s designers acted on the same experience. Checked exceptions don’t compose with higher-order functions: a Stream.map lambda can’t throw IOException, so Java code wraps and unwraps. They leak implementation details into signatures, so changing a storage backend changes every caller’s throws clause. And in practice, forced handling often produces empty catch blocks, which is worse than no handling at all.

So Kotlin treats every exception as unchecked. That doesn’t mean “ignore failure”. It means the language stops using exceptions as its main tool for expected outcomes, and gives you three tools for three different kinds of failure:

Kind of failureExampleKotlin tool
A bug: the caller or the code broke a ruleNegative quantity, wrong stateException via require/check/error; let it fail fast
An expected business outcomeUnknown SKU, suspended product, price jumpA value: nullable type or sealed error type, handled with when
An infrastructure failureFile missing, connection resetException, caught at a boundary that can do something about it

The shift for a Java developer: stop asking “which exception should this throw?” and start asking “is this a bug, an outcome, or an outage?” Outcomes become part of the return type, where the compiler checks that callers handle them, which is what checked exceptions promised and mostly failed to deliver.


Technical Explanation

No checked exceptions, and @Throws for Java

The annotated variant, and the Java caller:

// Fragment of story/FileBackedRepository.kt
// The same function, with the exception declared for Java's compiler.
@Throws(IOException::class)
fun loadChecked(): List<String> = Files.readAllLines(path)
}
fun main() {
val repo = FileBackedRepository(Path.of("missing-export.csv"))
try {
repo.load()
} catch (e: IOException) {
println("Kotlin caught ${e::class.simpleName}") // -> Kotlin caught NoSuchFileException
}
}
public final class PricingTeam {
public static void main(String[] args) {
FileBackedRepository repo = new FileBackedRepository(Path.of("missing-export.csv"));
// try { repo.load(); } catch (IOException e) { }
// error: exception IOException is never thrown in body of corresponding try statement
try {
repo.loadChecked(); // @Throws puts 'throws IOException' in the bytecode, so javac allows the catch
} catch (IOException e) {
System.out.println("Java caught " + e.getClass().getSimpleName()); // -> Java caught NoSuchFileException
}
}
}

The JVM itself has no checked exceptions; they are a javac rule. javac enforces them by reading the Exceptions attribute of each method in the bytecode. Kotlin writes that attribute only when asked, through @Throws:

// javap -p -v FileBackedRepository (excerpt)
public final java.util.List<java.lang.String> load();
public final java.util.List<java.lang.String> loadChecked() throws java.io.IOException;
Exceptions:
throws java.io.IOException

Without it, javac rejects the pricing team’s catch (IOException e) as unreachable, with the error in the comment above. With it, Java callers get the usual checked-exception contract. Put @Throws on every Kotlin function that Java code calls and that can throw a checked exception. The other direction needs nothing: Kotlin calls Java methods that declare throws IOException without any try.

try as an expression, preconditions and use

import java.io.StringReader
// try is an expression: the value of the try block or of the catch block.
fun parsePaise(text: String): Long? =
try {
text.trim().toLong()
} catch (e: NumberFormatException) {
null
}
class PriceList(private val prices: MutableMap<String, Long>) {
var frozen = false
fun priceOf(sku: String): Long {
require(sku.startsWith("SHW-")) { "not a Shelfwise SKU: $sku" } // caller's fault: IllegalArgumentException
return prices[sku] ?: error("no price for $sku") // error() throws IllegalStateException, type Nothing
}
fun update(sku: String, paise: Long) {
check(!frozen) { "price list is frozen" } // object's state is wrong: IllegalStateException
prices[sku] = paise
}
}
// Runs a block and prints the exception it throws, if any.
fun attempt(block: () -> Unit) {
try {
block()
} catch (e: RuntimeException) {
println("${e::class.simpleName}: ${e.message}")
}
}
fun main() {
println(parsePaise(" 16500 ")) // -> 16500
println(parsePaise("16,500")) // -> null
val list = PriceList(mutableMapOf("SHW-1001" to 16_500))
attempt { list.priceOf("1001") } // -> IllegalArgumentException: not a Shelfwise SKU: 1001
attempt { list.priceOf("SHW-2040") } // -> IllegalStateException: no price for SHW-2040
attempt { list.frozen = true; list.update("SHW-1001", 1) } // -> IllegalStateException: price list is frozen
// requireNotNull throws IllegalArgumentException, not the NullPointerException of Objects.requireNonNull.
val supplierCode: String? = null
attempt { requireNotNull(supplierCode) { "supplier code missing" } } // -> IllegalArgumentException: supplier code missing
// use: try-with-resources as an extension on Closeable/AutoCloseable.
val lines = StringReader("SHW-1001,16500\nSHW-2040,72000").buffered().use { it.readLines() }
println(lines.size) // -> 2
}

try returns the value of whichever block ran, so parsePaise needs no temporary variable. The precondition functions separate two kinds of bug in the exception type: require (the caller passed something wrong) throws IllegalArgumentException, check (the object is in the wrong state) throws IllegalStateException, and error(message) throws IllegalStateException and has the return type Nothing, which is why it works on the right of ?: (Part 2). Their message lambdas are only evaluated on failure. Note the last line: requireNotNull is a require, so it throws IllegalArgumentException, where Objects.requireNonNull throws NullPointerException. use closes the resource whether the block returns or throws, and records a suppressed exception from close() just as try-with-resources does.

Result and runCatching

import java.io.IOException
import kotlin.coroutines.cancellation.CancellationException
fun supplierPrice(sku: String): Long = when (sku) {
"SHW-1001" -> 15_900
"SHW-9999" -> throw IOException("supplier timeout")
else -> throw IllegalArgumentException("unknown SKU $sku")
}
fun main() {
// runCatching turns an exception into a value: Result<T> is either Success or Failure.
val ok = runCatching { supplierPrice("SHW-1001") }
val timeout = runCatching { supplierPrice("SHW-9999") }
println(ok) // -> Success(15900)
println(timeout) // -> Failure(java.io.IOException: supplier timeout)
println(ok.map { it / 100 }.getOrThrow()) // -> 159
println(timeout.getOrElse { 0L }) // -> 0
println(timeout.fold(onSuccess = { "price $it" }, onFailure = { "failed: ${it.message}" })) // -> failed: supplier timeout
// Recover only what you expect. recoverCatching keeps a rethrown exception inside the Result;
// plain recover would let it escape to the caller.
val recovered = runCatching { supplierPrice("SHW-0000") }
.recoverCatching { if (it is IOException) -1L else throw it }
println(recovered.exceptionOrNull()?.message) // -> unknown SKU SHW-0000
// The trap: runCatching catches Throwable. Cancellation and Errors become ordinary failures.
println(runCatching { throw CancellationException("job cancelled") }.isFailure) // -> true
println(runCatching { throw StackOverflowError() }.isFailure) // -> true
// A narrow catch isn't automatically safe: CancellationException IS an IllegalStateException.
try {
throw CancellationException("job cancelled")
} catch (e: IllegalStateException) {
println("caught as IllegalStateException: ${e.message}") // -> caught as IllegalStateException: job cancelled
}
}

Result<T> is a value class (Part 4) that holds either a value or a Throwable. runCatching builds one, and map, getOrElse, fold and recoverCatching work with it without try blocks. It is useful at boundaries where a failure must become data: a batch that records failures per item, or a reply to a message.

Its weakness is that it catches Throwable (see the Gotchas). Never expose Result in an API that Java calls: it is a value class, so a member function returning it gets a mangled name Java can’t call, and a top-level one returns a bare Object.

Sealed error types

// A generic outcome type. Nothing (Part 2) and 'out' (Part 8) make Ok and Err fit any Outcome<T, E>.
sealed interface Outcome<out T, out E> {
data class Ok<out T>(val value: T) : Outcome<T, Nothing>
data class Err<out E>(val error: E) : Outcome<Nothing, E>
}
// The errors a price lookup can produce, as a closed set the caller must handle.
sealed interface LookupError {
data class UnknownSku(val sku: String) : LookupError
data class Suspended(val sku: String, val reason: String) : LookupError
}
fun lookupPrice(sku: String): Outcome<Long, LookupError> = when (sku) {
"SHW-1001" -> Outcome.Ok(16_500)
"SHW-2040" -> Outcome.Err(LookupError.Suspended(sku, "supplier recall"))
else -> Outcome.Err(LookupError.UnknownSku(sku))
}
fun shelfLabel(sku: String): String = when (val outcome = lookupPrice(sku)) {
is Outcome.Ok -> "$sku ₹${outcome.value / 100}"
is Outcome.Err -> when (val error = outcome.error) {
is LookupError.UnknownSku -> "$sku NOT FOUND"
is LookupError.Suspended -> "$sku UNAVAILABLE (${error.reason})"
}
}
fun main() {
listOf("SHW-1001", "SHW-2040", "SHW-0000").forEach { println(shelfLabel(it)) }
// -> SHW-1001 ₹165
// -> SHW-2040 UNAVAILABLE (supplier recall)
// -> SHW-0000 NOT FOUND
}

This is the pattern for outcomes. lookupPrice’s return type says it can fail, and how: the caller must handle UnknownSku and Suspended, and the compiler checks exhaustiveness (Part 4). Add a third error and every when that handles lookups stops compiling. Checked exceptions promised this, but they can’t travel through a Stream.map lambda, and they force every layer in between to wrap or redeclare.

Outcome uses two earlier tools. Nothing is a subtype of every type, so Ok<T> is an Outcome<T, Nothing>, and because both type parameters are out (Part 8), it is also an Outcome<T, LookupError>. Many teams use a library type such as Arrow’s Either for this. A twenty-line sealed interface of your own is also fine.

The unused return value checker (Experimental)

// Experimental in 2.4.20 — requires -Xreturn-value-checker=check (set in this module's build.gradle.kts).
// Every function in a @MustUseReturnValues class must have its result used.
@MustUseReturnValues
class PriceCalculator {
fun discounted(pricePaise: Long, percent: Int): Long = pricePaise * (100 - percent) / 100
// Opt a function back out: callers may ignore the result.
@IgnorableReturnValue
fun record(pricePaise: Long): Boolean = pricePaise > 0
}
fun main() {
val calculator = PriceCalculator()
var price = 16_500L
calculator.discounted(price, 10) // warning: unused return value of 'discounted'.
println(price) // -> 16500
price = calculator.discounted(price, 10)
println(price) // -> 14850
calculator.record(price) // fine: @IgnorableReturnValue
val _ = calculator.discounted(price, 5) // an explicit "I mean to ignore this"
}

Value-based error handling has one hole: nothing forces a caller to look at the returned value. calculator.discounted(price, 10) on its own line computes a price and throws it away, a classic bug with immutable APIs. The unused return value checker, Experimental since Kotlin 2.3.0 and still Experimental in 2.4.20, warns about it when enabled per module with -Xreturn-value-checker=check:

warning: unused return value of 'discounted'.

In check mode it reports calls to declarations marked @MustUseReturnValues (on a class or file) and to the standard library functions JetBrains has already marked, such as map. full mode treats every function declared in the module as marked. @IgnorableReturnValue opts a function out, and val _ = … ignores one result deliberately. Note that val _ is itself experimental: without the checker flag it is rejected with “the feature “unnamed local variables” is experimental”. Expect the flag names to change before the feature is Stable.

Sidebar — Rich errors are not here yet. JetBrains has proposed rich errors: error union types that would let a signature say fun lookup(sku: String): Price | UnknownSku, with the compiler tracking which errors are handled. It is the language-level version of the sealed Outcome above. As of Kotlin 2.4.20 it is a proposal planned as Experimental for 2.5; no released compiler supports it. Model errors with sealed types today.

Annotations and use-site targets

// Shaped like a Bean Validation annotation: it may target parameters, fields and getters, but not Kotlin properties.
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY_GETTER)
@Retention(AnnotationRetention.RUNTIME)
annotation class MinPaise(val value: Long)
class PriceUpdate(
@MinPaise(100) val defaulted: Long, // 2.4.0 default: the parameter, and the field
@field:MinPaise(100) val onField: Long, // only the field
@get:MinPaise(100) val onGetter: Long, // only the getter
@all:MinPaise(100) val everywhere: Long, // @all (Stable since 2.4.0): parameter, field and getter
)
fun main() {
val type = PriceUpdate::class.java
val names = listOf("defaulted", "onField", "onGetter", "everywhere")
val parameters = type.constructors.single().parameters
for ((index, name) in names.withIndex()) {
val getter = "get" + name.replaceFirstChar { it.uppercase() }
val places = buildList {
if (parameters[index].isAnnotationPresent(MinPaise::class.java)) add("parameter")
if (type.getDeclaredField(name).isAnnotationPresent(MinPaise::class.java)) add("field")
if (type.getMethod(getter).isAnnotationPresent(MinPaise::class.java)) add("getter")
}
println("$name: $places")
}
// -> defaulted: [parameter, field]
// -> onField: [field]
// -> onGetter: [getter]
// -> everywhere: [parameter, field, getter]
}

One Kotlin declaration, val defaulted: Long in a primary constructor, produces a constructor parameter, a property, a backing field and a getter. A Java annotation has to land on some of those, and which ones matters: Bean Validation reads fields or getters, Jackson reads constructor parameters, JPA reads fields.

Kotlin 2.4.0 made new defaulting rules Stable. An annotation without an explicit target goes on the constructor parameter if allowed, and also on the property, or on the field if the property isn’t an allowed target. For a Bean Validation-style annotation like @MinPaise, that means parameter and field, the first line of output. Before 2.4.0 it went on the parameter only, which Bean Validation ignores when validating a bean, so @NotBlank val name: String in a request DTO silently validated nothing. That is why older Kotlin code is full of @field:NotBlank. Explicit targets still win: @field:, @get:, @param: and @property: pick one place. @all:, also Stable since 2.4.0, puts the annotation everywhere it is allowed. Part 13 relies on these rules for request validation in Spring.

Reflection

import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
data class Product(val sku: String, val name: String, val pricePaise: Long)
fun main() {
val product = Product("SHW-1001", "Toor Dal 1kg", 16_500)
// Without kotlin-reflect: class references, names, and property references you can call.
val type = Product::class
println(type.simpleName) // -> Product
println(type.java.name) // -> com.shelfwise.part09.reflection.Product
val nameProperty: KProperty1<Product, String> = Product::name
println("${nameProperty.name} = ${nameProperty.get(product)}") // -> name = Toor Dal 1kg
// With kotlin-reflect on the classpath: full introspection of Kotlin declarations.
val properties = type.memberProperties.sortedBy { it.name }.map { "${it.name}: ${it.returnType}" }
println(properties) // -> [name: kotlin.String, pricePaise: kotlin.Long, sku: kotlin.String]
println(type.isData) // -> true
}

Product::class is a KClass<Product>, Kotlin’s view of a class, and Product::class.java is the java.lang.Class that Java APIs expect. Property references such as Product::name are typed KProperty1<Product, String> and can be read without any extra library. Anything that inspects Kotlin-specific declarations, such as memberProperties, isData, whether a parameter is optional, or the nullability of types, needs org.jetbrains.kotlin:kotlin-reflect: 3.8 MB in 2.4.20, and noticeably slow on first use. Without it on the classpath, those calls still compile, and fail at runtime with KotlinReflectionNotSupportedError. Frameworks pull it in when they need it: Spring and Jackson’s Kotlin module both use it to understand constructors and nullability. Application code rarely should.


Step-by-Step Hands-On: A Supplier Price Import

Code: kotlin-for-java-survivors/language/part09-errors-without-checked-exceptions (file importer/PriceImport.kt).

The FileBackedRepository from the opening became a nightly supplier price import. Kabir rewrites it with the three-way split: rejected lines are values, I/O failures are exceptions, and one boundary decides what to catch.

Step 1 — Rejections as a sealed type. Every way a line can be refused, each with the data a person needs to fix it:

// Fragment of importer/PriceImport.kt
// Step 1: what a supplier line can become. Expected problems are data, not exceptions.
data class PriceUpdate(val sku: String, val newPaise: Long)
sealed interface Rejection {
val line: Int
data class Malformed(override val line: Int, val text: String) : Rejection
data class UnknownSku(override val line: Int, val sku: String) : Rejection
data class PriceJump(override val line: Int, val sku: String, val fromPaise: Long, val toPaise: Long) : Rejection
}

Step 2 — Validation returns an Outcome. No exceptions for a malformed line, an unknown SKU, or a price that jumps more than 50% (which needs a human):

// Fragment of importer/PriceImport.kt
// Step 2: validation is a pure function that returns an Outcome (from domain/DomainErrors.kt).
fun validate(line: Int, text: String, current: Map<String, Long>): Outcome<PriceUpdate, Rejection> {
val parts = text.split(",")
val paise = parts.getOrNull(1)?.trim()?.toLongOrNull()
if (parts.size != 2 || paise == null) return Outcome.Err(Rejection.Malformed(line, text))
val sku = parts[0].trim()
val old = current[sku] ?: return Outcome.Err(Rejection.UnknownSku(line, sku))
if (paise > old * 3 / 2) return Outcome.Err(Rejection.PriceJump(line, sku, old, paise))
return Outcome.Ok(PriceUpdate(sku, paise))
}
data class ImportReport(val accepted: List<PriceUpdate>, val rejected: List<Rejection>)

Step 3 — Reading stays exception-based. useLines closes the reader however the block ends:

// Fragment of importer/PriceImport.kt
// Step 3: reading stays exception-based: an I/O failure is not a property of one line.
// @Throws tells Java callers (and javac) about it.
@Throws(IOException::class)
fun importPrices(source: Reader, current: Map<String, Long>): ImportReport {
val accepted = mutableListOf<PriceUpdate>()
val rejected = mutableListOf<Rejection>()
source.buffered().useLines { lines ->
lines.forEachIndexed { index, text ->
when (val outcome = validate(index + 1, text, current)) {
is Outcome.Ok -> accepted += outcome.value
is Outcome.Err -> rejected += outcome.error
}
}
}
return ImportReport(accepted, rejected)
}

Step 4 — A narrow boundary. catchingIo turns only IOException into a Result:

// Fragment of importer/PriceImport.kt
// Step 4: a boundary that turns I/O failures into a Result, and lets everything else through:
// cancellation, Errors and bugs keep propagating, unlike with runCatching.
inline fun <T> catchingIo(block: () -> T): Result<T> =
try {
Result.success(block())
} catch (e: IOException) {
Result.failure(e)
}
fun describe(rejection: Rejection): String = when (rejection) {
is Rejection.Malformed -> "line ${rejection.line}: can't read '${rejection.text}'"
is Rejection.UnknownSku -> "line ${rejection.line}: unknown SKU ${rejection.sku}"
is Rejection.PriceJump -> "line ${rejection.line}: ${rejection.sku} jumps ${rejection.fromPaise} -> ${rejection.toPaise}, needs review"
}

Step 5 — Run it. The happy path calls importPrices directly; the boundary is for the source that can fail:

// Fragment of importer/PriceImport.kt
// Step 5: run it on a good file and a broken connection.
fun main() {
val current = mapOf("SHW-1001" to 16_500L, "SHW-2040" to 72_000L)
val supplierFile = "SHW-1001,15900\nSHW-2040,180000\nSHW-7777,500\nnot a price line"
val report = importPrices(StringReader(supplierFile), current) // a StringReader can't fail
println(report.accepted) // -> [PriceUpdate(sku=SHW-1001, newPaise=15900)]
report.rejected.forEach { println(describe(it)) }
// -> line 2: SHW-2040 jumps 72000 -> 180000, needs review
// -> line 3: unknown SKU SHW-7777
// -> line 4: can't read 'not a price line'
val brokenConnection = object : Reader() {
override fun read(buffer: CharArray, offset: Int, length: Int): Int = throw IOException("connection reset")
override fun close() {}
}
val failed = catchingIo { importPrices(brokenConnection, current) }
println("import failed: ${failed.exceptionOrNull()?.message}") // -> import failed: connection reset
}

One accepted update, three rejections a person can act on, and an outage reported as an outage. The price-jump rule is the interesting one: in the Java version it was an InvalidPriceException thrown from deep inside the parser, caught three layers up, and logged without the line number.


Tips, Tricks & Gotchas

Gotcha — runCatching swallows cancellation, and so can a narrow catch. runCatching catches Throwable: a CancellationException becomes a Result.failure, and so do OutOfMemoryError and StackOverflowError, which a Java catch (Exception e) never touches and Vavr’s Try rethrows as fatal. In coroutines (Part 10), cancellation is delivered as that exception, so swallowing it keeps a cancelled operation running. Narrowing the catch is not automatically safe either: Kotlin’s CancellationException is java.util.concurrent.CancellationException, an IllegalStateException, so catch (e: IllegalStateException) catches it too (the last lines of the Result example). Catch the types you mean, as catchingIo does with IOException, and rethrow CancellationException from broad catches.

Gotcha — Java implementations and proxies need @Throws on the interface. Two Java-side failures follow from an interface method without @Throws. A Java class implementing it can’t declare throws IOException (javac: “overridden method does not throw IOException”). And a JDK dynamic proxy wraps an undeclared checked exception in UndeclaredThrowableException. You meet JDK proxies with hand-rolled Proxy code, interface-only client proxies, and Spring AOP configured with proxyTargetClass = false; Spring Boot’s default CGLIB proxies special-case Kotlin classes and pass the original exception through:

import java.io.IOException
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Proxy
import java.lang.reflect.UndeclaredThrowableException
interface PriceSource {
fun load(): List<String> // no @Throws: Java sees no 'throws IOException'
}
interface CheckedPriceSource {
@Throws(IOException::class)
fun load(): List<String> // Java implementations may declare 'throws IOException'
}
class SupplierFeed : PriceSource {
override fun load(): List<String> = throw IOException("feed offline")
}
fun main() {
// A JDK dynamic proxy (what Spring AOP uses for interfaces) may only rethrow exceptions
// the interface method declares. An undeclared checked exception gets wrapped.
val target = SupplierFeed()
val proxy = Proxy.newProxyInstance(PriceSource::class.java.classLoader, arrayOf(PriceSource::class.java)) { _, method, args ->
try {
method.invoke(target, *(args ?: emptyArray()))
} catch (e: InvocationTargetException) {
throw e.targetException
}
} as PriceSource
try {
proxy.load()
} catch (e: UndeclaredThrowableException) {
println("${e::class.simpleName} caused by ${e.cause}") // -> UndeclaredThrowableException caused by java.io.IOException: feed offline
}
// A Java class implements CheckedPriceSource with 'throws IOException' (see JavaFeed.java).
try {
com.shelfwise.part09.javacaller.JavaFeed().load()
} catch (e: IOException) {
println("Java implementation threw: ${e.message}") // -> Java implementation threw: export locked
}
}
// Implementing a Kotlin interface from Java. Against PriceSource (no @Throws), this fails:
// error: load() in JavaFeed cannot implement load() in PriceSource
// overridden method does not throw IOException
public final class JavaFeed implements CheckedPriceSource {
@Override
public List<String> load() throws IOException {
throw new IOException("export locked");
}
}

Gotcha — requireNotNull is not Objects.requireNonNull. It throws IllegalArgumentException. Tests ported from Java that expect a NullPointerException fail, and so does exception mapping keyed on NPE.

Gotcha — Spring still sees checked exceptions. @Transactional rolls back on unchecked exceptions only, by default. A Kotlin function that throws IOException throws a checked exception as far as Spring is concerned, so the transaction commits. Since Spring Framework 6.2, @EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS) switches the default globally, and its documentation recommends it for Kotlin applications. Part 13 uses it.

Tip — upgrading to 2.4 can switch validation on. DTOs written with bare @NotBlank on constructor properties, and no @field:, were silently unvalidated before 2.4.0. After the upgrade the annotation also lands on the field, and validation starts rejecting requests it used to accept. Read the validation test results after upgrading, not just the compile output.


Key Takeaways

ConceptRemember
Checked exceptionsNone in Kotlin; @Throws writes the Exceptions attribute, for Java callers, Java implementers and proxies
Bug / outcome / outagerequire/check/error for bugs; sealed or nullable return types for outcomes; exceptions at boundaries for outages
tryAn expression; use replaces try-with-resources
Result / runCatchingCatches Throwable, including cancellation and Errors; recoverCatching keeps rethrows inside; never in Java-facing APIs
Sealed error typesExhaustive when replaces catch; Nothing + out make Ok/Err fit
Unused return valuesExperimental checker: -Xreturn-value-checker=check, @MustUseReturnValues, @IgnorableReturnValue
Rich errorsProposed, planned as Experimental for 2.5; not available in 2.4.20
Annotation targets2.4.0 default: parameter plus property (or field); @field:/@get:/@all: to choose
ReflectionKClass vs Class (::class.java); full Kotlin reflection needs kotlin-reflect

Story Closing

The import ran that night without a stack trace. The morning report listed eleven price jumps for review and one supplier whose feed was malformed from line 400 onwards, the kind of information the old job had logged as InvalidPriceException: null.

The next request came from the buying team. Before each import, prices should be checked against all forty supplier APIs, about two seconds each. Kabir’s first version used a fixed pool of sixteen threads and CompletableFuture.supplyAsync per supplier. In the load test, three imports at once meant 120 calls queued behind sixteen threads that spent almost all their time waiting on sockets.

His second version swapped the pool for Executors.newVirtualThreadPerTaskExecutor(), and the throughput problem disappeared. The next one appeared during the demo: the buying team cancelled an import halfway, and its forty calls kept running, because cancelling a CompletableFuture doesn’t stop the work behind it.

“Virtual threads made waiting cheap,” Lena said. “They didn’t give your forty calls a parent. That’s the part coroutines are for.”

In Part 10, Kabir meets coroutines: suspend functions, structured concurrency, cancellation, and what the compiler turns them into.


This is Part 9 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”