Three ways to say “this can fail”
Imagine ordering a dish that turns out to be sold out. A good waiter comes back and tells you, so you can choose something else. A bad waiter brings an empty plate and leaves you guessing. A terrible one sets the kitchen on fire.
Functions have the same choice when they can’t do their job:
- Report an error that the caller must deal with (the good waiter). In Swift you
throw; in Kotlin, Java and TypeScript you throw an exception (in TypeScript, usually anErrorobject). - Return “nothing”, like
nil,nullorundefined(the empty plate). Fine when “not found” is a normal answer, but it can’t say why. - Crash (the fire). Right only when the program itself is broken, not when the data is bad.
The four languages share the mechanism (unwinding to the nearest handler) but disagree on what the type system tracks:
- Swift marks fallible functions with
throwsand every call site withtry, so failure points are visible in code review. Errors are values conforming toError, usually enums. Swift 6 adds typed throws (throws(ParseError)), andResult<T, E>covers stored or callback-based outcomes. - Kotlin has only unchecked exceptions: nothing in a signature says a function throws, so documentation,
Result/sealed return types and@Throws(for Java callers) carry that information. - Java splits checked exceptionsChecked exceptionA Java exception the compiler forces you to either catch or declare in the method signature.Open in glossary → (must be declared and handled) from unchecked
RuntimeExceptions, and its lambda-based APIs mostly can’t throw checked ones. - TypeScript tracks nothing: any value can be thrown, catch variables are
unknown(understrict), and asynchronous failures are rejected promises, which crash Node when nobody handles them.
The Detailed version covers where this goes wrong in production: cancellation treated as an error, finally swallowing results, and designing an error model for a whole networking layer.
enum AgeError: Error {
case notANumber(String)
case outOfRange(Int)
}
func parseAge(_ text: String) throws -> Int {
guard let age = Int(text.trimmingCharacters(in: .whitespaces)) else {
throw AgeError.notANumber(text)
}
guard (0...130).contains(age) else {
throw AgeError.outOfRange(age)
}
return age
}
do {
let age = try parseAge("abc")
print("Age: \(age)")
} catch AgeError.notANumber(let text) {
print("\"\(text)\" isn't a number")
} catch {
print("Something else went wrong: \(error)")
}class AgeException(message: String) : IllegalArgumentException(message)
fun parseAge(text: String): Int {
val age = text.trim().toIntOrNull()
?: throw AgeException("\"$text\" isn't a number")
if (age !in 0..130) throw AgeException("$age is out of range")
return age
}
try {
val age = parseAge("abc")
println("Age: $age")
} catch (e: AgeException) {
println(e.message)
}class AgeException extends Exception {
AgeException(String message) { super(message); }
}
static int parseAge(String text) throws AgeException {
int age;
try {
age = Integer.parseInt(text.trim());
} catch (NumberFormatException e) {
throw new AgeException("\"" + text + "\" isn't a number");
}
if (age < 0 || age > 130) throw new AgeException(age + " is out of range");
return age;
}
try {
int age = parseAge("abc");
System.out.println("Age: " + age);
} catch (AgeException e) {
System.out.println(e.getMessage());
}class AgeError extends Error {
constructor(message: string, readonly reason: "notANumber" | "outOfRange") {
super(message);
this.name = "AgeError";
}
}
function parseAge(text: string): number {
const age = Number(text.trim());
if (text.trim() === "" || !Number.isInteger(age)) {
throw new AgeError(`"${text}" isn't a number`, "notANumber");
}
if (age < 0 || age > 130) throw new AgeError(`${age} is out of range`, "outOfRange");
return age;
}
try {
const age = parseAge("abc");
console.log(`Age: ${age}`);
} catch (e) {
if (e instanceof AgeError) console.log(e.message); // e is unknown until you check it
else throw e; // not ours: let it through
}What to notice in each language:
- Swift makes errors visible: the function says
throws, and every call needstry. Your own errors are usually anenum, one case per thing that can go wrong, so the caller can react differently to each. - Kotlin doesn’t mark functions that throw. You have to read the documentation (or the code) to know
parseAgecan fail, which is why many Kotlin APIs prefer returningnullor a result type for expected failures. - TypeScript doesn’t mark functions that throw either, and anything can be thrown, so the caught value
ehas the typeunknown: check it withinstanceofbefore you use it, and rethrow what you don’t handle. - Java has checked exceptions: because
AgeExceptionextendsException, the compiler forces every caller to catch it or declarethrows AgeException. Exceptions that extendRuntimeException(likeNumberFormatException) are unchecked and aren’t enforced.
Error, null or crash?
| Situation | Best tool | Why |
|---|---|---|
| “Not found” is a normal answer (search by id) | nil / null / Optional / undefined | The caller only needs to know whether, not why |
| Something can fail for several reasons the caller handles differently (login) | Throw a specific error, or return a result type | The caller needs to know why |
| The data is bad (user input, server response) | Throw or return an error, never crash | Bad data is expected; the app must survive it |
| The program itself is broken (an impossible state, a bundled file missing) | Crash with a clear message (preconditionFailure, error(), IllegalStateException, throw new Error) | Hiding a bug makes it harder to find |
Checked exceptions, typed throws and Result
Java’s checked exceptions put failure in the signature, which is the right idea and an awkward implementation: they don’t compose with lambdas (stream().map(this::load) can’t throw IOException), they leak implementation details up the stack (SQLException in a service interface), and they invite catch (Exception e) { throw new RuntimeException(e); }. Modern Java code tends to use them for recoverable I/O at the edges and unchecked exceptions inside, with UncheckedIOException as the bridge. Kotlin dropped them entirely; annotate with @Throws(IOException::class) when Java callers need to catch them. TypeScript never had them, and its proposals for typed throws were rejected, so library authors document errors or return result unions.
Swift’s untyped throws keeps the call site explicit (try) without fixing the error type. Typed throws (throws(ParseError), Swift 6) is worth it in narrow, self-contained places: a parser, embedded code with no allocation for any Error, and generic code that should propagate exactly its closure’s error (rethrows done right). For public APIs, untyped throws stays more evolvable: adding a case to a typed error is a breaking change.
Result types are for failures you store or pass around: callback APIs, collected validation errors, state in a ViewModel. In TypeScript, a discriminated union ({ ok: true; value: T } | { ok: false; error: E }) is the idiomatic result, and the only way to make expected failures visible in a type. In Kotlin, prefer your own sealed result for domain outcomes; kotlin.Result and runCatching catch Throwable, including CancellationException and Errors, which is the puzzle below.
| Swift | Kotlin | Java | TypeScript | |
|---|---|---|---|---|
| Declare failure | throws, throws(E) (6.0) | nothing (unchecked); @Throws for Java callers | throws (checked) / unchecked | nothing; anything can be thrown |
| Call site | try, try?, try! | plain call | plain call | plain call (await for promises) |
| Catch | do { } catch E.case { } | try { } catch (e: E) { } (an expression) | try { } catch (E e) { } | try { } catch (e) { }, e is unknown |
| Stored outcome | Result<T, E> | sealed result type; Result (catches everything) | sealed result type / CompletableFuture | union { ok: true; value } | { ok: false; error } |
Swift
Typed throws fits self-contained code (parsers, generic code that rethrows exactly its closure’s error); for public APIs, untyped throws is more evolvable, because adding a case to a typed error breaks callers.
Kotlin
No checked exceptions: annotate with @Throws(IOException::class) when Java callers must catch them. Prefer your own sealed result types for domain outcomes over kotlin.Result.
Java
Checked exceptions don’t cross lambdas (Function.apply declares none) and leak implementation details; use them at the I/O edges and bridge with UncheckedIOException.
TypeScript
With strict, catch variables are unknown: check with instanceof before using them. A rejected promise nobody handles crashes Node (since v15). For expected failures, return a discriminated union instead of throwing.
Check this part
What must you write when calling a function marked
throws?How can you tell from a Kotlin function's signature that it throws?What is a checked exception in Java?How can you tell from a TypeScript function's type that it throws?When is returning "nothing" (
nil,null,undefined) better than throwing an error?When is Swift's typed throws (
throws(MyError)) a better fit than plainthrows?How do Java callers find out that a Kotlin function throwsIOException?Why can'tstream().map(this::load)use a method that throwsIOException?Withstricton, what is the type ofeincatch (e)?