Skip to content
Commitwise
Menu
Programming

Error handling

Report, handle and recover from failures with Swift throws, Kotlin exceptions and Result, Java checked exceptions and TypeScript errors, without swallowing the errors that matter.

beginner

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

A user opens your notes app after an update and all their notes are gone. No crash, no error message. The new version couldn’t read the old file format, quietly treated that as “no notes”, and saved an empty list over the old file.

The app didn’t fail because something went wrong. Things always go wrong: networks drop, files get corrupted, users type letters into number fields. It failed because the code hid the error. This tutorial shows how Swift, Kotlin, Java and TypeScript report failures, and how to handle them so users and developers find out.

New to some of these? Open what you need, or skip ahead.

Basics:What are functions and parameters?

A function is a named piece of code you can run again and again. It takes parameters (inputs) and can return a result:

Swift:   func add(_ a: Int, _ b: Int) -> Int { a + b }
Kotlin:  fun add(a: Int, b: Int): Int = a + b
Java:    static int add(int a, int b) { return a + b; }
TS:      function add(a: number, b: number): number { return a + b; }

Calling it, add(2, 3), runs the code with a = 2 and b = 3 and gives back 5. A function that belongs to a class is usually called a method.

Basics:What are classes and objects?

A class is a blueprint; an object (or instance) is one thing built from it. A User class says every user has a name and an email; User(name: "Ana", email: "ana@example.com") creates one specific user.

  • Properties (fields) are the data each object holds.
  • Methods are functions that belong to the class and work with that data.
  • Swift also has struct, Kotlin has data class and Java has record: shorter ways to define types that mostly hold data.
Basics:What is inheritance?

A class can extend (inherit from) another class: class AdminUser : User (Kotlin), class AdminUser extends User (Java), class AdminUser: User (Swift). The subclass gets everything the parent has and can add more, or override a method to change what it does.

Inheritance is useful but couples the two classes tightly: a change in the parent can break every subclass. Many designs prefer composition (an object holding another object) instead, which several tutorials here show.

Basics:What is a stack trace?

When a program crashes or logs an exception, it prints a stack trace: the list of function calls that were in progress at that moment, innermost first, with file names and line numbers:

java.lang.IllegalStateException: Book 42 not found
    at com.example.BookService.find(BookService.java:27)
    at com.example.BookController.get(BookController.java:18)

Read it from the top: the first line says what went wrong, the next lines say where. It’s the most useful piece of a bug report, which is why code that replaces an exception should keep the original one as its cause.

Basics:What is JSON?

JSON is the text format most APIs use to send data. Objects are written in braces with "key": value pairs, lists in square brackets:

{ "id": 7, "name": "Mug", "price": 9.5, "tags": ["kitchen", "gift"], "discount": null }

Values can be strings (in quotes), numbers, true/false, null, objects or lists. Turning JSON into your own types is called decoding (or parsing, or deserializing); the opposite is encoding.

Basics:What is an API?

An API (application programming interface) is the set of operations a system offers to other programs. In app development it usually means a web API: a server with URLs your app calls to read and change data, like GET /api/products to list products.

The API is a contract: the app relies on its URLs, fields and status codes. Changing them carelessly breaks apps that are already installed on people’s phones.

Part Not started

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 an Error object).
  • Return “nothing”, like nil, null or undefined (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 throws and every call site with try, so failure points are visible in code review. Errors are values conforming to Error, usually enums. Swift 6 adds typed throws (throws(ParseError)), and Result<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 (under strict), 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 needs try. Your own errors are usually an enum, 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 parseAge can fail, which is why many Kotlin APIs prefer returning null or a result type for expected failures.
  • TypeScript doesn’t mark functions that throw either, and anything can be thrown, so the caught value e has the type unknown: check it with instanceof before you use it, and rethrow what you don’t handle.
  • Java has checked exceptions: because AgeException extends Exception, the compiler forces every caller to catch it or declare throws AgeException. Exceptions that extend RuntimeException (like NumberFormatException) are unchecked and aren’t enforced.

Error, null or crash?

SituationBest toolWhy
“Not found” is a normal answer (search by id)nil / null / Optional / undefinedThe 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 typeThe caller needs to know why
The data is bad (user input, server response)Throw or return an error, never crashBad 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.

SwiftKotlinJavaTypeScript
Declare failurethrows, throws(E) (6.0)nothing (unchecked); @Throws for Java callersthrows (checked) / uncheckednothing; anything can be thrown
Call sitetry, try?, try!plain callplain callplain call (await for promises)
Catchdo { } catch E.case { }try { } catch (e: E) { } (an expression)try { } catch (E e) { }try { } catch (e) { }, e is unknown
Stored outcomeResult<T, E>sealed result type; Result (catches everything)sealed result type / CompletableFutureunion { 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

  1. 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?

  2. When is returning "nothing" (nil, null, undefined) better than throwing an error?

  3. When is Swift's typed throws (throws(MyError)) a better fit than plain throws?How do Java callers find out that a Kotlin function throws IOException?Why can't stream().map(this::load) use a method that throws IOException?With strict on, what is the type of e in catch (e)?

A failing function can report an error, return nothing, or crash. Throw when the caller needs to know why; crash only when the program itself is broken.

Part Not started

The error that disappeared

Here’s the notes app from the beginning. Find the line that deleted everyone’s notes:

Spot the bugNotes that vanish after an update

Tap the line with the bug.

func loadNotes() -> [Note] {
    guard let data = try? Data(contentsOf: notesURL) else { return [] }
    return (try? JSONDecoder().decode([Note].self, from: data)) ?? []
}

func addNote(_ note: Note) throws {
    var notes = loadNotes()
    notes.append(note)
    try JSONEncoder().encode(notes).write(to: notesURL)
}
fun loadNotes(): List<Note> = try {
    Json.decodeFromString(notesFile.readText())
} catch (e: Exception) {
    emptyList()
}

fun addNote(note: Note) {
    val notes = loadNotes() + note
    notesFile.writeText(Json.encodeToString(notes))
}
List<Note> loadNotes() {
    try {
        return mapper.readValue(notesFile, new TypeReference<List<Note>>() {});
    } catch (IOException e) {
        return new ArrayList<>();
    }
}

void addNote(Note note) throws IOException {
    List<Note> notes = new ArrayList<>(loadNotes());
    notes.add(note);
    mapper.writeValue(notesFile, notes);
}
async function loadNotes(): Promise<Note[]> {
  try {
    return JSON.parse(await readFile(notesPath, "utf8"));
  } catch {
    return [];
  }
}

async function addNote(note: Note) {
  const notes = await loadNotes();
  await writeFile(notesPath, JSON.stringify([...notes, note]));
}

Practice: errors users understand

A login screen can fail in several ways, and “Something went wrong” helps nobody. Step by step:

  1. List the failures the user can act on: no internet, wrong password, account locked, server problem.
  2. Make them one error type, so the screen can switch over them.
  3. Translate at the edge: the networking code turns technical failures (an IOException, HTTP 401) into those cases.
  4. Map each case to a message and an action in the UI.
enum LoginError: Error {
    case offline
    case wrongCredentials
    case locked(until: Date)
    case server
}

func message(for error: LoginError) -> String {
    switch error {
    case .offline:          return "You're offline. Check your connection and try again."
    case .wrongCredentials: return "Wrong email or password."
    case .locked(let date): return "Too many attempts. Try again after \(date.formatted(date: .omitted, time: .shortened))."
    case .server:           return "Our servers are having trouble. Try again in a minute."
    }
}
sealed class LoginException(message: String) : Exception(message) {
    class Offline : LoginException("offline")
    class WrongCredentials : LoginException("wrong credentials")
    class Locked(val until: Instant) : LoginException("locked")
    class Server(cause: Throwable) : LoginException("server error") { init { initCause(cause) } }
}

fun message(e: LoginException): String = when (e) {
    is LoginException.Offline -> "You're offline. Check your connection and try again."
    is LoginException.WrongCredentials -> "Wrong email or password."
    is LoginException.Locked -> "Too many attempts. Try again later."
    is LoginException.Server -> "Our servers are having trouble. Try again in a minute."
}
sealed abstract class LoginException extends Exception
        permits Offline, WrongCredentials, Locked, ServerError {
    LoginException(String message, Throwable cause) { super(message, cause); }
}
final class Offline extends LoginException { Offline(Throwable c) { super("offline", c); } }
final class WrongCredentials extends LoginException { WrongCredentials() { super("wrong credentials", null); } }
final class Locked extends LoginException {
    final Instant until;
    Locked(Instant until) { super("locked", null); this.until = until; }
}
final class ServerError extends LoginException { ServerError(Throwable c) { super("server error", c); } }

static String message(LoginException e) {
    return switch (e) {
        case Offline o -> "You're offline. Check your connection and try again.";
        case WrongCredentials w -> "Wrong email or password.";
        case Locked l -> "Too many attempts. Try again later.";
        case ServerError s -> "Our servers are having trouble. Try again in a minute.";
    };
}
type LoginError =
  | { kind: "offline" }
  | { kind: "wrongCredentials" }
  | { kind: "locked"; until: Date }
  | { kind: "server"; cause: unknown };

function message(error: LoginError): string {
  switch (error.kind) {
    case "offline":          return "You're offline. Check your connection and try again.";
    case "wrongCredentials": return "Wrong email or password.";
    case "locked":           return `Too many attempts. Try again after ${error.until.toLocaleTimeString()}.`;
    case "server":           return "Our servers are having trouble. Try again in a minute.";
  }
}

When you translate one error into another, keep the original. Which line loses the information a developer needs?

Spot the bugAn error report that says nothing

Tap the line with the bug.

func loadProfile() async throws -> Profile {
    do {
        return try await api.profile()
    } catch {
        throw ProfileError.loadFailed
    }
}
suspend fun loadProfile(): Profile {
    try {
        return api.profile()
    } catch (e: IOException) {
        throw ProfileException("Could not load profile")
    }
}
Profile loadProfile() throws ProfileException {
    try {
        return api.profile();
    } catch (IOException e) {
        throw new ProfileException("Could not load profile");
    }
}
async function loadProfile(): Promise<Profile> {
  try {
    return await api.profile();
  } catch (e) {
    throw new ProfileError("Could not load profile");
  }
}

Exerciseeasy

Parse a settings line

A settings file has lines like port=8080 and host=example.com. Write parseLine(line) that returns the key and value, and reports which problem a bad line has:

  • no = in the line,
  • an empty key (=8080),
  • for the key port, a value that isn’t a number from 1 to 65535.
"port=8080"     → ("port", "8080")
"host"          → missing "="
"=8080"         → empty key
"port=http"     → invalid port: "http"
›

Define one error type with a case (or subclass) per problem, with the data a message would need, like the invalid value.

›

Split on the first = only: values like token=abc=def contain = too.

SolutionTry it yourself firstTime O(n)Space O(n)
enum SettingsError: Error, Equatable {
    case missingEquals(line: String)
    case emptyKey
    case invalidPort(String)
}

func parseLine(_ line: String) throws -> (key: String, value: String) {
    guard let eq = line.firstIndex(of: "=") else { throw SettingsError.missingEquals(line: line) }
    let key = line[..<eq].trimmingCharacters(in: .whitespaces)
    let value = line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)
    guard !key.isEmpty else { throw SettingsError.emptyKey }
    if key == "port" {
        guard let port = Int(value), (1...65535).contains(port) else { throw SettingsError.invalidPort(value) }
    }
    return (key, value)
}
sealed class SettingsException(message: String) : Exception(message) {
    class MissingEquals(line: String) : SettingsException("missing \"=\" in \"$line\"")
    class EmptyKey : SettingsException("empty key")
    class InvalidPort(value: String) : SettingsException("invalid port: \"$value\"")
}

fun parseLine(line: String): Pair<String, String> {
    val eq = line.indexOf('=')
    if (eq < 0) throw SettingsException.MissingEquals(line)
    val key = line.substring(0, eq).trim()
    val value = line.substring(eq + 1).trim()
    if (key.isEmpty()) throw SettingsException.EmptyKey()
    if (key == "port" && value.toIntOrNull()?.takeIf { it in 1..65535 } == null) {
        throw SettingsException.InvalidPort(value)
    }
    return key to value
}
class SettingsException extends Exception {
    SettingsException(String message) { super(message); }
}

record Setting(String key, String value) {}

static Setting parseLine(String line) throws SettingsException {
    int eq = line.indexOf('=');
    if (eq < 0) throw new SettingsException("missing \"=\" in \"" + line + "\"");
    String key = line.substring(0, eq).trim();
    String value = line.substring(eq + 1).trim();
    if (key.isEmpty()) throw new SettingsException("empty key");
    if (key.equals("port")) {
        try {
            int port = Integer.parseInt(value);
            if (port < 1 || port > 65535) throw new SettingsException("invalid port: \"" + value + "\"");
        } catch (NumberFormatException e) {
            throw new SettingsException("invalid port: \"" + value + "\"");
        }
    }
    return new Setting(key, value);
}
class SettingsError extends Error {
  constructor(message: string, readonly problem: "missingEquals" | "emptyKey" | "invalidPort") {
    super(message);
    this.name = "SettingsError";
  }
}

function parseLine(line: string): [key: string, value: string] {
  const eq = line.indexOf("=");
  if (eq < 0) throw new SettingsError(`missing "=" in "${line}"`, "missingEquals");
  const key = line.slice(0, eq).trim();
  const value = line.slice(eq + 1).trim();
  if (key === "") throw new SettingsError("empty key", "emptyKey");
  if (key === "port") {
    const port = Number(value);
    if (!Number.isInteger(port) || port < 1 || port > 65535) {
      throw new SettingsError(`invalid port: "${value}"`, "invalidPort");
    }
  }
  return [key, value];
}

Each error tells the user exactly which line to fix and how, and a caller can collect them all and show every problem at once instead of stopping at the first one. Write a test per row of the example table: errors are part of a function’s behavior, so they deserve tests too.

Errors at boundaries

Each layer should speak its own error language:

  • Data layer: transport, HTTP status and decoding failures, with the raw details kept as causes.
  • Domain layer: what the failure means for the feature (wrongCredentials, outOfStock), independent of how it was detected.
  • UI layer: what the user can do (retry, sign in again, contact support), with a message and maybe an action.

Translate once per boundary, keep the cause chain, and log at the boundary where you handle the error (not at every layer it passes through, or one failure becomes five log entries). Strip personal data from error messages before they reach analytics.

Exercisemedium

An error model for the networking layer

Your app’s networking code currently throws whatever the HTTP client throws, and every screen handles errors differently: some retry forever, some show raw messages like The data couldn't be read because it isn't in the correct format, some crash on cancellation.

Design one error model for the networking layer. It must let callers decide: retry or not, what to tell the user, whether to sign the user out, and what to log, and it must keep cancellation out of all of that.

›

Classify by what the caller can do, not by where the failure came from: transport problems, HTTP statuses (client vs server, and 401 specifically), undecodable responses, and domain errors returned by the API in its error body.

›

Put the policy on the type: a computed isRetryable and a user-facing category, so screens don’t each reinvent “is 503 worth retrying?”. Retrying is only safe for idempotent requests.

SolutionTry it yourself first
enum APIError: Error {
    case transport(URLError)                     // offline, timeout, DNS, TLS
    case http(status: Int, problem: ProblemDetail?)
    case decoding(DecodingError)                 // a contract bug: log loudly

    var isRetryable: Bool {
        switch self {
        case .transport(let e):        return e.code != .cancelled
        case .http(let status, _):     return status == 408 || status == 429 || status >= 500
        case .decoding:                return false
        }
    }

    var requiresSignIn: Bool {
        if case .http(401, _) = self { return true }
        return false
    }
}

func send<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
    let data: Data, response: URLResponse
    do {
        (data, response) = try await session.data(for: request)
    } catch let error as URLError where error.code == .cancelled {
        throw CancellationError()                // one way to say "cancelled"
    } catch let error as URLError {
        throw APIError.transport(error)
    }
    let status = (response as? HTTPURLResponse)?.statusCode ?? 0
    guard (200..<300).contains(status) else {
        throw APIError.http(status: status, problem: try? decoder.decode(ProblemDetail.self, from: data))
    }
    do {
        return try decoder.decode(T.self, from: data)
    } catch let error as DecodingError {
        throw APIError.decoding(error)
    }
}
sealed class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause) {
    /** No response: offline, DNS, timeout, TLS. */
    class Transport(cause: IOException) : ApiException("transport", cause)

    /** The server answered with an error status and maybe a problem-details body. */
    class Http(val status: Int, val problem: ProblemDetail?) : ApiException("HTTP $status")

    /** 2xx, but the body doesn't match the contract: a bug on one side. */
    class Decoding(cause: Throwable) : ApiException("decoding", cause)

    val isRetryable: Boolean
        get() = when (this) {
            is Transport -> true
            is Http -> status == 408 || status == 429 || status >= 500
            is Decoding -> false
        }

    val requiresSignIn: Boolean get() = this is Http && status == 401
}

suspend fun <T> withRetry(idempotent: Boolean, block: suspend () -> T): T {
    var attempt = 0
    while (true) {
        try {
            return block()
        } catch (e: CancellationException) {
            throw e                                       // never classified, never retried
        } catch (e: ApiException) {
            if (!e.isRetryable || !idempotent || ++attempt >= 3) throw e
            delay(backoffWithJitter(attempt))            // and honor Retry-After when present
        }
    }
}
public sealed abstract class ApiException extends RuntimeException
        permits TransportException, HttpException, DecodingException {
    ApiException(String message, Throwable cause) { super(message, cause); }
    public abstract boolean isRetryable();
}

public final class TransportException extends ApiException {
    public TransportException(IOException cause) { super("transport", cause); }
    public boolean isRetryable() { return true; }
}

public final class HttpException extends ApiException {
    public final int status;
    public final ProblemDetail problem;
    public HttpException(int status, ProblemDetail problem) { super("HTTP " + status, null); this.status = status; this.problem = problem; }
    public boolean isRetryable() { return status == 408 || status == 429 || status >= 500; }
    public boolean requiresSignIn() { return status == 401; }
}

public final class DecodingException extends ApiException {
    public DecodingException(Throwable cause) { super("decoding", cause); }
    public boolean isRetryable() { return false; }
}
class ApiError extends Error {
  constructor(
    readonly kind: "transport" | "http" | "decoding",
    readonly status?: number,
    options?: { cause?: unknown },
  ) {
    super(kind === "http" ? `HTTP ${status}` : kind, options);
    this.name = "ApiError";
  }

  get isRetryable(): boolean {
    if (this.kind === "transport") return true;
    if (this.kind === "http") return this.status === 408 || this.status === 429 || this.status! >= 500;
    return false;                                   // decoding: a contract bug
  }

  get requiresSignIn(): boolean {
    return this.kind === "http" && this.status === 401;
  }
}

async function send<T>(url: string, signal: AbortSignal, parse: (json: unknown) => T): Promise<T> {
  let response: Response;
  try {
    response = await fetch(url, { signal });
  } catch (e) {
    if (signal.aborted) throw e;                   // cancellation: never wrapped, never retried
    throw new ApiError("transport", undefined, { cause: e });
  }
  if (!response.ok) throw new ApiError("http", response.status);
  try {
    return parse(await response.json());          // a schema check (Zod, Valibot) inside parse
  } catch (e) {
    throw new ApiError("decoding", response.status, { cause: e });
  }
}

How the pieces fit:

  • Three technical categories, decided once in the networking layer. Screens never inspect URLError codes or IOException subclasses.
  • Domain errors come from the body: the API returns problem details with a stable type (/errors/out-of-stock), and the repository maps those to domain errors (CheckoutError.outOfStock(sku)) for the feature. Don’t parse human-readable detail text.
  • Retry policy lives with the error, with backoff and jitter, honors Retry-After, and only for idempotent requests (or requests with an idempotency key). 401 triggers one token refresh and replay, then sign-out; it’s handled centrally, not per screen.
  • User messages map from categories: offline (“check your connection”), retryable server trouble (“try again”), non-retryable (“we’re looking into it”), with the request id for support.
  • Logging: Decoding errors are bugs and go to crash reporting with the endpoint and app version (never the body, which may contain personal data); transport errors are metrics, not crash reports.
  • Cancellation is never an ApiException: it’s rethrown everywhere and handled by nobody, which is exactly right.

Deep dives

Swift

Deep diveTyped throwsInterview favorite

Since Swift 6, a function can declare which error it throws: func parse() throws(ParseError) -> Order. Callers then get a ParseError in catch instead of any Error, and generic code can propagate an error type without boxing it (often where you’d have used rethrows).

The Swift book says most code doesn’t specify the error type, and keeps typed throws for three cases: embedded systems without dynamic allocation, errors that are an implementation detail inside a library or module, and generic code that only forwards errors. In a public API, a typed error is part of the contract: adding a new kind of failure later is a breaking change.

Kotlin

Deep diverunCatching swallows cancellationInterview favorite

runCatching { … } catches every Throwable, including CancellationException, which is how coroutines are cancelled. Inside a coroutine, runCatching { api.load() } turns “the user left the screen” into a Result.failure, and the code keeps going as if a network error happened: retries, error UI, logs.

Kotlin has no checked exceptions by design (they compose badly with lambdas and invite empty catch blocks), so this is easy to miss. In coroutine code, catch specific exceptions, or rethrow CancellationException (getOrElse { if (it is CancellationException) throw it; … }).

Java

Deep diveSuppressed exceptions in try-with-resourcesInterview favorite

If the body of a try throws and then close() throws too, which exception do you see? With the old try/finally pattern, the one from finally: the original cause was lost, and logs showed only “stream closed”.

try-with-resources keeps the original exception and attaches the one from close() with addSuppressed; e.getSuppressed() returns them, and stack traces print them under “Suppressed:”. It’s a favorite interview question because it explains why try-with-resources is more than shorter syntax.

TypeScript

Deep diveAnything can be thrownInterview favorite

JavaScript lets you throw any value: a string, an object, undefined. That’s why, under strict, the variable in catch (e) has type unknown, not Error, and you narrow it (if (e instanceof Error)) before reading e.message.

Two related habits: wrap errors with new Error("Loading orders failed", { cause: e }) (ES2022) so the original is kept, and handle promise rejections. A rejected promise nobody awaits is an “unhandled rejection”, which terminates a Node process by default since Node 15.

  • try?, empty catch blocks and “return an empty default” say I don’t care what went wrong. Make it a decision: a missing file is “empty”, an unreadable one is an error that must never be overwritten.
  • Classify errors by what the caller can do (retry, sign in, report a bug), translate them once per layer (data → domain → UI), keep the cause chain, and log where the error is handled, not at every layer.
  • Retry only idempotent requests, and only 408, 429 and 5xx, with backoff, jitter and Retry-After, from one central policy.
SwiftKotlinJavaTypeScript
Wrap with causeassociated value underlying:Exception(msg, cause)new Exception(msg, cause)new Error(msg, { cause })

Check this part

  1. Why did loadNotes() returning an empty list on any error cause data loss?

  2. You convert a low-level error into your own error. What should you always do?

  3. An error passes through the data, domain and UI layers. Where should it be logged?

  4. Which failed requests should a central policy retry automatically?

Turning every failure into an empty default can destroy data. Treat failures differently, and keep the original error as the cause when you wrap it.

Part Not started

Cleaning up, whatever happens

Some code must run whether the work succeeds or fails: closing a file, hiding a spinner, releasing a lock. Swift has defer, Kotlin, Java and TypeScript have finally (and use, try-with-resources and using for things that need closing): The cleanup runs on every way out of the function, including errors and early returns, and it runs before the error reaches the caller’s catch.

For anything you open, prefer the form that closes it for you:

let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
let header = try handle.read(upToCount: 4)
val firstLine = File("notes.txt").bufferedReader().use { it.readLine() }   // closed after the block
try (var reader = Files.newBufferedReader(Path.of("notes.txt"))) {        // closed after the block
    String firstLine = reader.readLine();
}
const handle = await open("notes.txt");               // node:fs/promises
try {
  const text = await handle.readFile("utf8");
} finally {
  await handle.close();                               // closed whatever happens
}
// TypeScript 5.2+ on a recent Node: `await using handle = await open("notes.txt")` closes it for you

Predict the output

What gets printed, in order?

enum LoadError: Error { case corrupt }

func load() throws -> String {
    print("open")
    defer { print("close") }
    throw LoadError.corrupt
}

do {
    _ = try load()
} catch {
    print("failed")
}
fun load(): String {
    println("open")
    try {
        throw IllegalStateException("corrupt")
    } finally {
        println("close")
    }
}

try {
    load()
} catch (e: IllegalStateException) {
    println("failed")
}
static String load() {
    System.out.println("open");
    try {
        throw new IllegalStateException("corrupt");
    } finally {
        System.out.println("close");
    }
}

try {
    load();
} catch (IllegalStateException e) {
    System.out.println("failed");
}
function load(): string {
  console.log("open");
  try {
    throw new Error("corrupt");
  } finally {
    console.log("close");
  }
}

try {
  load();
} catch (e) {
  console.log("failed");
}

finally always wins

Predict the output

What does score() return?

func score() -> Int {
    defer { return 2 }
    return 1
}
fun score(): Int {
    try {
        return 1
    } finally {
        return 2
    }
}
static int score() {
    try {
        return 1;
    } finally {
        return 2;
    }
}
function score(): number {
  try {
    return 1;
  } finally {
    return 2;
  }
}
SwiftKotlinJavaTypeScript
Cleanupdeferfinally, use { }finally, try-with-resourcesfinally, using (TS 5.2+)

Swift

Control can’t leave a defer block: no return or throw inside it, so cleanup can never replace a result.

Kotlin, Java, TypeScript

A return in finally overrides the try’s result and silently swallows any exception thrown in try. Never return (or throw) from finally.

Check this part

  1. A function prints "open", throws, and prints "close" in its cleanup (defer / finally). The caller prints "failed" in its catch. In which order do the lines appear?

  2. What's the safest way to make sure a file you opened gets closed?

  3. func score() -> Int { defer { return 2 }; return 1 }. What happens?try { return 1 } finally { return 2 }. What does the function return?try { return 1; } finally { return 2; }. What does the method return?try { return 1; } finally { return 2; }. What does the function return?

Cleanup in defer, finally, use, try-with-resources or using runs on every exit path, before the error reaches the caller.

Part Not started

Cancellation is not an error

When the user leaves a screen, the work that screen started (loading a list, a search) is cancelled: nobody is waiting for the result anymore. Cancellation reaches your code the same way an error does, so a catch that shows “Couldn’t load items” will also show it for a request the user simply walked away from.

The rule is short: catch cancellation separately, and do nothing with it. No message, no retry.

In all four runtimes, cancellation travels through the error channel: Swift throws CancellationError (or URLError.cancelled), Kotlin throws CancellationException, Java signals InterruptedException and clears the thread’s interrupt flag, and an aborted AbortSignal makes fetch reject with an AbortError. All of them must be propagated, not handled like failures:

  • Swift: catch it separately and return quietly; never show an error for it.
  • Kotlin: rethrow CancellationException from any broad catch, or catch only the exceptions you expect.
  • Java: on InterruptedException, restore the flag (Thread.currentThread().interrupt()) and stop.
  • TypeScript: check signal.aborted (or e.name === "AbortError") first and return quietly; pass the signal down so nested work stops too.
Spot the bugErrors after the user already left

Tap the line with the bug.

@MainActor
func refresh() async {
    do {
        items = try await api.items()
    } catch {
        errorMessage = "Couldn't load items"
    }
}
fun refresh() = viewModelScope.launch {
    val result = runCatching { api.items() }
    result.onSuccess { items.value = it }
    result.onFailure { error.value = "Couldn't load items" }
    analytics.log("refresh_finished")
}
void pollForUpdates() {
    while (running) {
        try {
            fetchUpdates();
            Thread.sleep(5_000);
        } catch (InterruptedException e) {
            log.warn("Interrupted while polling, continuing");
        }
    }
}
async function refresh(signal: AbortSignal) {
  try {
    items = await api.items({ signal });
  } catch (e) {
    errorMessage = "Couldn't load items";
  }
}

Swift

CancellationError (or URLError.cancelled): catch it separately and return quietly; never show an error banner for it.

Kotlin

CancellationException must propagate: rethrow it from any broad catch. runCatching catches it, so a cancelled coroutine keeps running.

Java

InterruptedException clears the thread’s interrupt flag: restore it with Thread.currentThread().interrupt() and stop, or shutdown requests are lost.

TypeScript

An aborted AbortSignal makes fetch reject with a DOMException named AbortError: check signal.aborted (or e.name === "AbortError") and return quietly; never retry it.

Check this part

  1. The user leaves a screen while its request is running, and the code receives a cancellation error. What should the screen do?

  2. How does cancellation reach your code?

  3. A view's .task is cancelled while api.items() runs inside a do/catch that shows a banner. What should the code do?Why is runCatching { api.items() } risky inside a coroutine?Code catches InterruptedException and can't rethrow it. What should it do?An aborted fetch rejects inside a catch that shows an error. What should it do?

Cancelled work arrives through the error path, but it isn't a failure: don't show it, don't retry it, let it propagate.

Key takeaways

  • Report failures callers must handle by throwing a specific error; use nil/null/undefined only when “not found” is a normal answer.
  • Swift: throws + try + do/catch. Kotlin: unchecked exceptions. Java: checked exceptions must be caught or declared. TypeScript: anything can be thrown, and catch gets an unknown to check.
  • Never hide an error by habit: try?, empty catch blocks and “return an empty default” can destroy data.
  • Clean up with defer, finally, use, try-with-resources or using, and keep the original error as the cause when you wrap it.
  • Classify errors by what the caller can do (retry, sign in, report a bug), and translate them once per layer, keeping the cause chain.
  • Cancellation is not a failure: propagate CancellationError, CancellationException and interrupts, never show or retry them.
  • return in finally and catch-all runCatching hide bugs; typed throws and checked exceptions help locally but are hard to evolve.
  • Retry only idempotent requests, with backoff, jitter and Retry-After, from one central policy.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.