skip to content

Show how a raw type can compile cleanly (apart from a warning) yet cause a ClassCastException at runtime, and explain exactly where and why the exception is thrown.

level: seniorimportance: should knowfreq 44%

answer

  1. Bad `add` through raw = warning only, runs fine
  2. Same underlying list; Integer is silently stored
  3. Erasure: runtime list is just Object-list (no insert check)
  4. Compiler inserts cast on READ → CCE at read site
  5. Crash is far from the cause

basics

~20 s

If you add a wrong-typed object through a raw List, it compiles (with a warning). The crash happens later, when other code reads an element expecting a specific type — the compiler-inserted cast fails and throws ClassCastException.

solid answer

~50 s

Using a raw type lets you insert objects of the wrong type because the compiler stops checking. For example, you take a `List<String>`, assign it to a raw `List`, and `add` an `Integer` — that only warns. Nothing fails at insertion. The failure is *deferred*: when some other code iterates the list as `List<String>`, the compiler has inserted an implicit cast to `String` on each read (since erasure removed the type at runtime, the cast is the only enforcement left). That cast hits the `Integer` and throws `ClassCastException` — at the *read* site, not the bad `add`. This is the central danger of raw types: they convert a compile-time error into a runtime error that surfaces far from its cause, making it hard to debug. The fix is to never use the raw reference; keep it `List<String>` so the bad `add` is a compile error.

code

java · 10 lines
java
List<String> strings = new ArrayList<>();

List raw = strings;   // parameterized -> raw: allowed, no warning
raw.add(42);          // unchecked WARNING only; compiles and runs, stores Integer

// Elsewhere, code that legitimately reads it as List<String>:
for (String s : strings) {            // compiler inserts: (String) it.next()
    System.out.println(s.length());   // ClassCastException thrown HERE, not at add
}
// Exception: java.lang.Integer cannot be cast to java.lang.String

go deeper

for a junior

Understands that a raw type can let bad data in and that it eventually crashes, even if hazy on exactly where.

for a middle

Can write the reproduction and state that the CCE happens on read, not on the bad add.

for a senior

Explains the compiler-inserted cast on reads, ties the silent insert to erasure (runtime Object-list), and articulates the cause-vs-symptom distance as the real hazard.

for a principal

Generalizes to the soundness implications of erasure, contrasts with reified generics, and prescribes boundary-isolation/validation patterns when legacy raw APIs are unavoidable.

## The mechanism in one sentence A raw type lets you smuggle a wrong-typed object into a generic collection without a compile error; **type erasure** removes the element type from the runtime, so the only remaining enforcement is the **compiler-inserted cast at each read site** — and that cast is where the `ClassCastException` finally fires. ## A minimal reproduction ```java List<String> strings = new ArrayList<>(); List raw = strings; // parameterized -> raw: OK, no warning raw.add(42); // unchecked warning ONLY; compiles & runs fine // somewhere else, code that legitimately treats it as List<String>: for (String s : strings) { // <-- ClassCastException thrown HERE System.out.println(s.length()); } ``` Note: `strings` and `raw` point at the *same* underlying `ArrayList`. The `add(42)` autoboxes to an `Integer` and stores it. No exception occurs at `add`. ## Why nothing fails at `add(42)` Because `raw` is a raw type, the compiler treats `raw.add(...)` as `add(Object)` — it accepts any reference and only **warns** (unchecked). At runtime the `ArrayList` simply stores the `Integer`; an `ArrayList` genuinely holds `Object` references after erasure, so there is no runtime type tag to reject it. Insertion is therefore completely silent at runtime. ## Why it fails at the read This is the key insight. When you wrote the enhanced-for over `strings` (declared `List<String>`), the compiler knew the element type was `String`. After erasure, `list.get(i)` returns `Object`, so the compiler **inserts a synthetic checkcast to `String`** so your `String s` variable is type-correct. The decompiled loop body is effectively: ```java String s = (String) iterator.next(); // compiler-inserted cast ``` When `next()` returns the `Integer 42`, `(String) 42` fails → **`ClassCastException: java.lang.Integer cannot be cast to java.lang.String`**, thrown at the loop, not at the `add`. ## Why this is dangerous: distance between cause and symptom The `add(42)` (the actual bug) and the crash can be in **different methods, classes, or even modules**, separated in time. The stack trace points at the innocent read site, giving no direct clue about who inserted the bad element. Raw types thus convert a clean compile-time failure into a hard-to-localize runtime failure — the exact opposite of what generics were designed to give you. ## What erasure has to do with it If the JVM retained element types (reified generics, as in some other languages), `add(42)` could be rejected at runtime immediately. Java erases generics for backward compatibility, so the runtime list is just "a list of Objects," and the *only* place the intended type is re-asserted is the compiler-generated cast on reads. Remove the compile-time checking (via a raw type) and you remove the early guard, leaving only the late one. ## The fix Keep the reference parameterized: ```java List<String> strings = new ArrayList<>(); strings.add(42); // COMPILE ERROR — caught immediately, at the real cause ``` No raw type means the bad insertion is a compile error at the offending line. If you must interoperate with legacy raw APIs, isolate and validate at the boundary and suppress the single unchecked warning with a justification. ## Summary - Raw type → insertion of wrong type compiles (warning only) and runs silently. - Erasure leaves runtime lists as Object-lists; no insert-time check. - The compiler's implicit cast on each *read* is the sole enforcement; that's where CCE is thrown. - The symptom appears far from the cause — the core reason to avoid raw types.

  • Where exactly is the cast that throws the exception?
    It's a synthetic `checkcast` the compiler inserts at every read where it knows the parameterized element type — e.g. the `(String)` on `iterator.next()` in a for-each over a `List<String>`. The programmer never wrote it.
  • How would reified generics change this scenario?
    With reified generics the runtime would know the list's element type and could reject `add(42)` immediately at insertion, turning the late, far-away CCE into an early, local failure. Java erases instead, for backward compatibility.

saying these in an interview costs you the question

  • Claiming the ClassCastException is thrown at the `add` call
  • Thinking the JVM checks element types on insertion
  • Believing the program won't compile at all
  • Assuming the cast in the for-loop was written by the programmer rather than inserted by the compiler

context