Catch selection uses the exception's runtime type, not the declared type of any variable. Why does this matter, and how does it interact with checked-exception compile rules?
answer
- Runtime match = actual object class, not reference's static type
- Compiler: checked catch must be demonstrably throwable
- 'exception X is never thrown' = compile error for unused checked catch
- Unchecked (RuntimeException/Error) can be caught anywhere
- Two independent reachability checks: shadowing vs never-thrown
basics
~20 sAt runtime the JVM matches the actual class of the thrown object, even if a reference's declared type is broader. But the compiler separately checks, using declared throws clauses, that a catch for a checked exception is reachable — catching a checked type the try can't throw is a compile error.
solid answer
~50 sTwo different type checks are at play. At runtime, catch matching is purely dynamic: the JVM uses the thrown object's actual class and picks the first assignable clause, regardless of any reference's static type. So if a variable typed Exception actually holds a NullPointerException when thrown, catch(NullPointerException) matches. Separately, the compiler enforces a static rule for checked exceptions: a catch clause for a checked exception type is only legal if the try block can actually throw that type (per the declared throws of the methods it calls, or an explicit throw). Catching a checked exception that can't be thrown is the compile error 'exception X is never thrown in body of corresponding try'. Note this static reachability check applies to checked exceptions only — you may always catch RuntimeException, Exception, or Throwable since unchecked exceptions and Errors can occur anywhere. The two systems are independent: the compiler proves a clause could be reached; the JVM decides at runtime which one actually is.
code
java · 21 lines// (1) Runtime matching is by the object's actual class, not the reference's static type.
Exception problem = new NumberFormatException("bad");
try {
throw problem; // the actual NumberFormatException is thrown
} catch (NumberFormatException e) { // MATCHES at runtime
System.out.println("matched the real type");
}
// (2) Compile-time: catching a CHECKED type the try cannot throw is rejected.
try {
int x = 1 + 1; // nothing here throws a checked exception
} catch (java.io.IOException e) { // COMPILE ERROR: never thrown in body of try
// ...
}
// (3) But unchecked catches are always allowed, since they can occur anywhere.
try {
int x = 1 + 1;
} catch (RuntimeException e) { // compiles fine
// ...
}go deeper
Understands an exception's actual class decides the match, and may not yet know the checked-vs-unchecked compile rule.
Knows runtime matching uses the actual object and that catching a checked type the try can't throw is a compile error.
Cleanly separates the dynamic match from the static reachability checks, explains why Exception compiles where IOException doesn't, and predicts how refactors break checked-catch clauses.
Connects checked-exception contracts to API design and tooling, and reasons about how the type system's two reachability guarantees shape error-handling and refactoring safety.
## Two kinds of 'type' to keep straight When reasoning about try-catch you must separate **runtime matching** (what the JVM does when an exception is thrown) from **compile-time legality** (what the javac compiler will accept). ## Runtime: matching is by the object's actual class An exception is an object. Its **runtime type** is the class it was actually constructed as (`new NullPointerException(...)`), independent of the **static type** of any reference pointing at it. Catch matching is dynamic: the JVM walks the catch clauses and selects the first whose declared type is assignable **from the object's runtime class**. Why this matters: code often holds or returns exceptions through broad references. ```java Exception problem = computeProblem(); // static type Exception, runtime type maybe NumberFormatException try { throw problem; // throws the actual NumberFormatException object } catch (NumberFormatException e) { // MATCHES at runtime ... } ``` The lesson: never reason about which catch fires from a variable's declared type — only the constructed object's class counts. (This is the same polymorphism rule as `instanceof` and virtual dispatch.) ## Compile time: the checked-exception reachability rule Java splits exceptions into **checked** (subtypes of `Exception` excluding `RuntimeException`) and **unchecked** (`RuntimeException` and its subtypes, plus `Error` and its subtypes). Checked exceptions are part of a method's contract: a method must declare them with `throws`, and callers must handle or re-declare them. The compiler uses these declarations to police catch clauses for **checked** types: a `catch (SomeCheckedException e)` is only legal if the try block can **actually** throw `SomeCheckedException` — i.e. some method it calls declares it in `throws`, or there's an explicit `throw` of it (or a subtype). If nothing in the try can throw that checked type, the clause is provably dead and javac reports: > **error: exception SomeCheckedException is never thrown in body of corresponding try statement** ```java try { System.out.println("no IO here"); // nothing throws IOException } catch (IOException e) { // COMPILE ERROR: never thrown ... } ``` ## The crucial asymmetry: only checked types get this check Unchecked exceptions (`RuntimeException`, `Error` and their subtypes) can be thrown from **anywhere** — a `NullPointerException` can pop out of almost any expression, an `OutOfMemoryError` any allocation. The compiler therefore does **not** require the try to demonstrably throw them. You may always write `catch (RuntimeException e)`, `catch (NullPointerException e)`, `catch (Exception e)`, or `catch (Throwable e)` even over a try that contains no `throws`-declaring calls — these compile fine because such exceptions are always possible. This is exactly why a `catch (Exception e)` over arbitrary code compiles while a `catch (IOException e)` over the same code may not: `Exception` includes the always-possible unchecked subtypes, whereas `IOException` is purely checked and must be demonstrably throwable. ## How the two interact with ordering The unreachable-catch (subtype-after-supertype) rule and this never-thrown rule are *both* compile-time reachability checks, but on different axes: one says 'an earlier clause already covers this'; the other says 'nothing in the try can produce this checked type'. A given clause must pass both to compile. ## Practical implications - Don't predict catch behavior from a reference's static type; trace the actual object constructed. - If you remove the only `throws`-declaring call from a try, a previously-valid `catch` of that checked type becomes a compile error — refactors can surface this. - A `catch (Exception e)` is the pragmatic 'catch anything that's possible here' because it sidesteps the never-thrown check by including unchecked types — but it's blunt (see the breadth trade-offs).
- Why does catch(Exception e) compile over a try with no throws-declaring calls, but catch(IOException e) does not?Exception includes always-possible unchecked subtypes, so it's never 'provably never thrown'. IOException is purely checked, so if nothing in the try declares/throws it, the clause is dead and rejected with 'never thrown in body of corresponding try'.
- If a variable typed Throwable actually holds an ArithmeticException and is thrown, which catch fires?catch(ArithmeticException) (or any of its supertypes) fires, because matching uses the object's runtime class, not the Throwable static type of the reference.
saying these in an interview costs you the question
- Believing catch selection depends on a variable's declared/static type
- Thinking you can never catch an exception the try doesn't visibly throw — true only for checked types
- Assuming catch(RuntimeException) over arbitrary code is a compile error