"Errors Without Checked Exceptions" — Failure Modelling, Annotations and Reflection
The Java pricing team can't catch an IOException that Kotlin throws. We cover Kotlin's answer to checked exceptions: @Throws for Java callers, try as an expression, require/check/error, Result and runCatching (and their traps), sealed error types, the Experimental unused-return-value checker, annotation use-site targets under the Kotlin 2.4 defaults, and reflection basics.
Story Opening
Kabir answered the pricing team’s message in the thread, and pasted his function underneath:
// Fragment of story/FileBackedRepository.ktclass 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.readAllLinesthrowsNoSuchFileException, and Kotlin just lets it through. Kotlin has no checked exceptions, so nothing went into the bytecode’sthrowsclause, and javac thinks it can’t happen.Pricing lead: So we catch
Exceptionand 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
| Java | Kotlin | Note |
|---|---|---|
List<String> load() throws IOException | fun load(): List<String> | No checked exceptions; @Throws(IOException::class) for Java callers |
try/catch statement assigning a variable | val x = try { … } catch (…) { … } | try is an expression |
Preconditions.checkArgument, checkState | require(cond) { msg }, check(cond) { msg }, error(msg) | IllegalArgumentException / IllegalStateException |
Objects.requireNonNull(x, msg) | requireNotNull(x) { msg } | Throws IllegalArgumentException, not NPE |
| try-with-resources | resource.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 own | Typed errors |
Lombok @SneakyThrows | The default | Every exception is unchecked |
| Checked exception for a business outcome | Sealed error type | Exhaustive when instead of catch |
Error Prone @CheckReturnValue | @MustUseReturnValues | Experimental, -Xreturn-value-checker |
@NotNull on a field, getter or parameter | One declaration, several targets: @field:, @get:, @all: | Defaults changed in 2.4.0 |
Class<T>, Field, Method | KClass<T>, KProperty, KFunction | Full 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 failure | Example | Kotlin tool |
|---|---|---|
| A bug: the caller or the code broke a rule | Negative quantity, wrong state | Exception via require/check/error; let it fail fast |
| An expected business outcome | Unknown SKU, suspended product, price jump | A value: nullable type or sealed error type, handled with when |
| An infrastructure failure | File missing, connection reset | Exception, 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.IOExceptionWithout 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.IOExceptionimport 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.@MustUseReturnValuesclass 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 sealedOutcomeabove. 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.KProperty1import 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 —
runCatchingswallows cancellation, and so can a narrow catch.runCatchingcatchesThrowable: aCancellationExceptionbecomes aResult.failure, and so doOutOfMemoryErrorandStackOverflowError, which a Javacatch (Exception e)never touches and Vavr’sTryrethrows 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’sCancellationExceptionisjava.util.concurrent.CancellationException, anIllegalStateException, socatch (e: IllegalStateException)catches it too (the last lines of theResultexample). Catch the types you mean, ascatchingIodoes withIOException, and rethrowCancellationExceptionfrom broad catches.
Gotcha — Java implementations and proxies need
@Throwson the interface. Two Java-side failures follow from an interface method without@Throws. A Java class implementing it can’t declarethrows IOException(javac: “overridden method does not throw IOException”). And a JDK dynamic proxy wraps an undeclared checked exception inUndeclaredThrowableException. You meet JDK proxies with hand-rolledProxycode, interface-only client proxies, and Spring AOP configured withproxyTargetClass = false; Spring Boot’s default CGLIB proxies special-case Kotlin classes and pass the original exception through:
import java.io.IOExceptionimport java.lang.reflect.InvocationTargetExceptionimport java.lang.reflect.Proxyimport 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 IOExceptionpublic final class JavaFeed implements CheckedPriceSource { @Override public List<String> load() throws IOException { throw new IOException("export locked"); }}Gotcha —
requireNotNullis notObjects.requireNonNull. It throwsIllegalArgumentException. Tests ported from Java that expect aNullPointerExceptionfail, and so does exception mapping keyed on NPE.
Gotcha — Spring still sees checked exceptions.
@Transactionalrolls back on unchecked exceptions only, by default. A Kotlin function that throwsIOExceptionthrows 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
@NotBlankon 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
| Concept | Remember |
|---|---|
| Checked exceptions | None in Kotlin; @Throws writes the Exceptions attribute, for Java callers, Java implementers and proxies |
| Bug / outcome / outage | require/check/error for bugs; sealed or nullable return types for outcomes; exceptions at boundaries for outages |
try | An expression; use replaces try-with-resources |
Result / runCatching | Catches Throwable, including cancellation and Errors; recoverCatching keeps rethrows inside; never in Java-facing APIs |
| Sealed error types | Exhaustive when replaces catch; Nothing + out make Ok/Err fit |
| Unused return values | Experimental checker: -Xreturn-value-checker=check, @MustUseReturnValues, @IgnorableReturnValue |
| Rich errors | Proposed, planned as Experimental for 2.5; not available in 2.4.20 |
| Annotation targets | 2.4.0 default: parameter plus property (or field); @field:/@get:/@all: to choose |
| Reflection | KClass 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.”