Lesson 13 / 13
Exception Design
The same work gets built with three contracts: a checked exception, an unchecked exception, and a result type. The measurement shows the count of decision points the compiler forces on the calling side, and what a careless caller gets from an empty store; none of the three contracts eliminates the defect, they only move where it shows up.
Contents
The previous four lessons took the exception as a given: it got thrown, declared, caught, suppressed. None of them asked whether an event should be reported as an exception at all. The same work can be written with three separate contracts — a checked exception, an unchecked exception, or no exception at all, returning the result through a type.
This lesson builds all three around the same work: getting an item from a store that might be empty. This course’s question turns into a design question here, but the measure stays the same. Whoever chooses the contract chooses what the caller has to do; and the party doing the forcing is always the compiler.
The Decision Point the Compiler Forces
- GE27 — The three contracts get measured on the same store, with
the same three-layer calling chain:
innerdoes the work,middlecalls it,outercallsmiddle. - GE28 — The eight attempts are the set of which of the three points get the declaration. Since there is no declaration to place for the result type, that row’s subset count gets left blank.
// Scale.java - shared measurement core: compiles the given source
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.nio.file.*;
import java.util.spi.ToolProvider;
class Scale {
static ClassModel compile(String source, String className) throws Exception {
Path dir = Files.createTempDirectory("scale");
Path file = dir.resolve(className + ".java");
Files.writeString(file, source);
PrintWriter silent = new PrintWriter(Writer.nullWriter());
if (ToolProvider.findFirst("javac").orElseThrow()
.run(silent, silent, "-d", dir.toString(), file.toString()) != 0)
throw new IllegalStateException("did not compile: " + className);
return ClassFile.of().parse(dir.resolve(className + ".class"));
}
}
// Force.java - how many points three contracts force in the calling chain
public class Force {
static final String TYPES = """
class Event extends Exception { Event(String m) { super(m); } }
class SilentEvent extends RuntimeException { SilentEvent(String m) { super(m); } }
record Result(String value, String error) { }
class Store {
String checked() throws Event { throw new Event("empty"); }
String unchecked() { throw new SilentEvent("empty"); }
Result result() { return new Result(null, "empty"); }
}
""";
static String chain(String type, String call, String[] declarations) {
return TYPES + "class C {\n"
+ " static " + type + " inner(Store d) " + declarations[0]
+ " { return d." + call + "(); }\n"
+ " static " + type + " middle(Store d) " + declarations[1] + " { return inner(d); }\n"
+ " static " + type + " outer(Store d) " + declarations[2] + " { return middle(d); }\n}\n";
}
static boolean compiles(String source) {
try {
Scale.compile(source, "C");
return true;
} catch (Exception e) {
return false;
}
}
static void measure(String label, String type, String call, String declaration) {
boolean untouched = compiles(chain(type, call, new String[] {"", "", ""}));
int minimum = -1, passed = 0;
for (int m = 0; m < 8; m++) {
String[] declarations = new String[3];
int points = 0;
for (int i = 0; i < 3; i++) {
boolean present = (m >> i & 1) == 1;
declarations[i] = present ? declaration : "";
if (present) points++;
}
if (compiles(chain(type, call, declarations))) {
passed++;
if (minimum < 0 || points < minimum) minimum = points;
}
}
System.out.printf("%-16s %-20s %-10d %s%n", label,
untouched ? "compiled" : "did not compile", minimum,
declaration.isEmpty() ? "-" : passed + " / 8");
}
public static void main(String[] args) {
System.out.printf("%-16s %-20s %-10s %s%n",
"contract", "untouched chain", "min points", "compiled subset");
measure("checked", "String", "checked", "throws Event");
measure("unchecked", "String", "unchecked", "throws SilentEvent");
measure("result type", "Result", "result", "");
}
}
contract untouched chain min points compiled subset checked did not compile 3 1 / 8 unchecked compiled 0 8 / 8 result type compiled 0 -
The first row counts the cost the checked exception writes to the caller. The chain untouched anywhere does not compile, and the smallest declaration set that makes the chain compile has size three — that is, every layer. Only one of eight subsets compiles: the set where all three declare. The requirement cannot be stopped at a layer in between, because if one layer does not declare, the layer calling it never learns the exception exists at all.
The second and third rows give zero. For the unchecked exception, the chain compiles with no touch at all; all eight of eight subsets are valid, that is, declaration is entirely optional. For the result type, there is not even a declaration to place.
A measured comparison follows from this. A checked exception writes the error path’s existence into every layer of the chain; this is documentation, and it gets enforced by the compiler. In the other two contracts, the error path is invisible in any of the intermediate layers. What gets forced, though, is only declaring, not handling — and the next measurement shows what that means.
Same Work, Three Contracts
- GE29 — Two callers get written for each contract: the shortest writing the compiler accepts, and a writing that genuinely handles the error path. Both run against the same empty store and the same full store.
- GE30 — From the caller’s outcome, only the returned value or the class name of the escaped exception gets printed.
// Contract.java - same work, three contracts, a careless and a careful caller
import java.util.function.Function;
class Event extends Exception {
Event(String m) { super(m); }
}
class SilentEvent extends RuntimeException {
SilentEvent(String m) { super(m); }
}
record Result(String value, String error) { }
class Store {
private final String item;
Store(String item) { this.item = item; }
String checked() throws Event {
if (item == null) throw new Event("empty store");
return item;
}
String unchecked() {
if (item == null) throw new SilentEvent("empty store");
return item;
}
Result result() {
return item == null ? new Result(null, "empty store") : new Result(item, null);
}
}
public class Contract {
static String carelessChecked(Store d) {
try {
return d.checked();
} catch (Event e) {
return "";
}
}
static String carefulChecked(Store d) {
try {
return d.checked();
} catch (Event e) {
return "fallback (" + e.getMessage() + ")";
}
}
static String carefulUnchecked(Store d) {
try {
return d.unchecked();
} catch (SilentEvent e) {
return "fallback (" + e.getMessage() + ")";
}
}
static String carefulResult(Store d) {
Result s = d.result();
return s.error() == null ? s.value() : "fallback (" + s.error() + ")";
}
static String run(Function<Store, String> f, Store d) {
try {
return "value \"" + f.apply(d) + "\"";
} catch (RuntimeException e) {
return "exception " + e.getClass().getSimpleName();
}
}
static void row(String label, Function<Store, String> careless, Function<Store, String> careful) {
Store empty = new Store(null), full = new Store("north");
System.out.printf("%-16s %-26s %-26s %s%n", label,
run(careless, empty), run(careful, empty), run(careless, full));
}
public static void main(String[] args) {
System.out.printf("%-16s %-26s %-26s %s%n",
"contract", "careless / empty store", "careful / empty store", "careless / full store");
row("checked", Contract::carelessChecked, Contract::carefulChecked);
row("unchecked", d -> d.unchecked(), Contract::carefulUnchecked);
row("result type", d -> String.valueOf(d.result().value()), Contract::carefulResult);
}
}
contract careless / empty store careful / empty store careless / full store checked value "" value "fallback (empty store)" value "north" unchecked exception SilentEvent value "fallback (empty store)" value "north" result type value "null" value "fallback (empty store)" value "north"
The first column gives three separate defect shapes, and all three come out of the same empty store.
In the checked contract, the careless caller returns an empty string. The compiler forced a catch to be written, but never asked what got put inside it; an empty body satisfies the requirement completely. A checked exception’s guarantee is the error path being seen, not being handled.
In the unchecked contract, the careless caller fails at runtime;
the defect takes its loudest shape here. In the result type, it gets
stopped neither at compile time nor does it fail at runtime; it
returns the text null as a value. This is the quietest of the
three shapes, because the error path has become ordinary data, not an
exception.
The second column writes down the three contracts’ common point: once the error path genuinely gets handled, all three give the same result. The third column shows where the distinction lives — once the store is full, the three contracts are indistinguishable. Choosing a contract changes nothing on the correct path; it is entirely a decision about the error path.
The Defect Does Not Disappear, It Moves
This lesson’s boundary measurement is reading the two tables together, and it declares none of the three contracts superior.
The defect is singular: an item got requested from an empty store. The three contracts do not remove this defect, they only choose where it shows up. A checked exception carries it into the caller’s compilation — all three layers have to write the error path, but what they write is not required to be correct. An unchecked exception carries it into runtime; no layer of the source gets stained, but which call can fail cannot be seen by looking at the source. A result type carries it into the data layer; it never fails anywhere, but the wrong value keeps flowing.
In all three places, the one paying the price is a different party: in the first, whoever writes; in the second, whoever runs; in the third, whoever reads the result. The measure that has run through this course since its first lesson takes its final shape here — where a decision gets made decides where the defect shows up, and no choice eliminates the defect.
Summary
- A checked exception makes declaration mandatory in every layer of the call chain: only one of eight subsets compiles, and the smallest declaration set’s size is three.
- An unchecked exception and a result type force no point at all; the chain compiles untouched, and the error path is invisible in the intermediate layers’ source.
- What gets forced is declaring, not handling: a careless caller satisfies the requirement by writing an empty catch, and returns an empty value.
- The same empty store produces three separate defect shapes: an
empty value, a runtime exception, and a
nullturned into data. Once the error path gets handled, the three contracts give the same result; once the store is full, they cannot even be told apart. - Boundary measurement: none of the three contracts eliminates the defect. The defect is singular, and the contract only chooses where it shows up — at compilation, at throwing, or at reading.
Course Wrap-Up
This course opened with a single question: when a name carries two types, which one decides? Thirteen lessons asked this question of thirteen separate language decisions, and got the answer every time from an executed measurement. Gathered together, a single rule emerges: the behavior called looks at the runtime type, the name chosen looks at the declared type — and the boundary between the two is invisible in the syntax.
| Lesson | Measured decision | Deciding party | Boundary measurement |
|---|---|---|---|
| Class, Object, and Constructor | overridden method called from the constructor chain | runtime type | no trap is born in a private or final method, all three compile to the same instruction |
| Access Modifiers | visibility: 10 of 16 pairs compile | declared type (compile time) | the member stays put at runtime, only a flag carries the distinction |
| Inheritance and Overriding | four of eight and four of eight decisions | four runtime type, four declared type | overriding is not born when the signature does not match, the deciding party switches |
| Abstract Classes and Interfaces | eight setups of the same capability, three stop compilation | the compiler, without asking which side | a capability carrying state cannot be built with an interface |
| Static Members and Initialization Blocks | static method selection and class-level state | declared type | the distinction cannot be observed once the two sides coincide |
| The final Keyword | reassignment, overriding, and widening bounds | declared type (compile time) | final does not freeze the referenced object |
| Inner and Local Classes | generated class files and hidden fields | the compiler, without it being written in the source | a static nested class carries no hidden field |
| Records and Enumerations | which side answers for the generated members | runtime type | a record’s protection is shallow, an array component changes from outside |
| Generic Types | what the type parameter leaves behind at runtime | declared type, entirely | the signature attribute stays: what gets erased is not the information, it is the check |
| Bounded Wildcards | four container forms × two operations, four of eight | declared type, entirely | all four can be read as Object, all four can have null written to them |
| Exception Hierarchy | what the checked distinction forces: one of nine writings gets rejected | declared type for writing, runtime type for the branch | the virtual machine never checks the declaration |
| Try-with-Resources | closing order and the suppressed exception | the compiler, looking at the declared type | suppression’s direction runs one way, closing never suppresses the body’s |
| Exception Design | the decision point three contracts force: 3, 0, 0 | declared type (compile time) | none of the three contracts eliminates the defect, they move it |
The table’s third column is this course’s method; the fourth is where
each lesson bounds its own thesis. Read together, the course’s real
claim shows through: the difference is invisible in the syntax.
d.name() and d.label get written with a two-character difference
and get answered by separate parties; Store<Base> and
Store<String> get written separately and become a single class; a
throws declaration sits in the source and never gets checked at
runtime.
A second reading comes from generics, and is the shared lesson of the last five lessons. When a check gets moved to compile time, it does not disappear, it only relocates; and once a path opens that slips past that check, the defect shows up not where it was produced, but much later, where it gets read. A value written through the raw type falls at the reading line; without try-with-resources written, an exception gets lost at the closing line; when the wrong contract gets chosen, the defect turns into data.
In this course, classes always worked alone: a store, a superclass, a subclass. The next course, Standard Library and Streams, puts the same types inside collections and streams. There, the type parameter travels not in a single store, but through operations chained to one another; and the question “which side decided” becomes askable again, because every link in a stream carries its own declared type.
To keep your progress and take notes, Log in
My notes
Log in to take notes.