Types, Generics & Nullability

Post 1 changed how you think about Java values: everything is passed by value, == asks "same object?", .equals() asks "same value?", types are checked at compile time, and String is immutable. This post is about the type system itself — the machinery behind those rules. For a Python developer, Java's type system is the biggest daily difference you'll feel, and it will either read as bureaucracy or as a second pair of eyes. Which one depends on understanding what it's actually doing.

The honest frame for this whole post: every code block below ran on JDK 21 on my machine, and every output block is the real output, pasted verbatim. Python comparisons ran on Python 3.12. Where I show a compile error, I show the real javac message. Nothing here is "trust me."

1. Two worlds: primitives vs wrapper classes

In Python, every value is an object — 42 is a full int object, and Python integers have arbitrary precision. Java splits its world in two. Primitives are raw values with fixed sizes sitting directly in variables — no object, no methods, no null. Wrapper classes are real objects that wrap one primitive, and they live in java.lang. Here's the full map:

Python typeJava primitiveSizeWrapper class
boolboolean1 bit (JVM-dependent)Boolean
int (arbitrary precision)byte / short / int / long1 / 2 / 4 / 8 bytesByte / Short / Integer / Long
floatfloat / double4 / 8 bytesFloat / Double
str (single char is still a str)char2 bytes (UTF-16)Character
Two shocks for Python developers, up front. First: int is 32 bits and overflows silently — Integer.MAX_VALUE + 1 wraps to a negative number, no error. (You'll see this exact trap bite in the performance section below.) Second: a char is not a one-character string — it's a 16-bit number. 'A' + 1 is the integer 66, and casting back gives 'B'. Strings are String, characters are char, and never the twain shall meet without an explicit conversion.

Java converts between the two worlds automatically — autoboxing (primitive → wrapper) and unboxing (wrapper → primitive). It looks free. It isn't. But first, watch it work:

import java.util.ArrayList;
import java.util.List;

public class BoxingDemo {
    public static void main(String[] args) {
        // Autoboxing: int -> Integer (assignment does Integer.valueOf)
        Integer boxed = 42;
        // Unboxing: Integer -> int
        int primitive = boxed;

        System.out.println("boxed = " + boxed + " (" + boxed.getClass().getSimpleName() + ")");
        System.out.println("primitive = " + primitive);

        // Unboxing in arithmetic
        Integer a = 7, b = 6;
        int sum = a + b;                       // both unboxed, then added
        System.out.println("a + b = " + sum);

        // Collections of primitives are really collections of wrappers
        List<Integer> ints = new ArrayList<>();
        ints.add(1); ints.add(2); ints.add(3);  // int -> Integer on the way in
        int first = ints.get(0);               // Integer -> int on the way out
        System.out.println("list.get(0) = " + first);
    }
}

Real output:

boxed = 42 (Integer)
primitive = 42
a + b = 13
list.get(0) = 1
Why collections only hold wrappers: generics (section 3) only work with reference types, so List<int> doesn't exist — you write List<Integer> and autoboxing silently bridges the gap. This is why Java performance discussions always come back to boxing: your "list of ints" is secretly a list of objects.

The Integer-cache trap (post 1 comes back to haunt you)

Post 1 taught you: == compares object identity, .equals() compares values. Now watch that rule collide with autoboxing. The JVM caches Integer objects for values -128 to 127 (the language spec guarantees it). Inside that range, two boxes of the same value are the same object; outside it, they aren't:

public class IntegerCacheTrap {
    public static void main(String[] args) {
        Integer a127 = Integer.valueOf(127);
        Integer b127 = Integer.valueOf(127);
        System.out.println("127: a == b        -> " + (a127 == b127));   // cached: same object

        Integer a128 = Integer.valueOf(128);
        Integer b128 = Integer.valueOf(128);
        System.out.println("128: a == b        -> " + (a128 == b128));   // NOT cached: different objects

        System.out.println("128: a.equals(b)   -> " + a128.equals(b128)); // value comparison: true
        System.out.println("identityHashCode 127: " + System.identityHashCode(a127)
                + " vs " + System.identityHashCode(b127));
        System.out.println("identityHashCode 128: " + System.identityHashCode(a128)
                + " vs " + System.identityHashCode(b128));
    }
}

Real output:

127: a == b        -> true
128: a == b        -> false
128: a.equals(b)   -> true
identityHashCode 127: 2125039532 vs 2125039532
identityHashCode 128: 1581781576 vs 1725154839

Read that again: 127 == 127 is true and 128 == 128 is false — on boxed integers. The identityHashCode lines prove it: identical hashes at 127 (same object), different hashes at 128 (two distinct objects with equal values). In Python, is on small ints has a similar cache, but Python style is to use == for values anyway — which is exactly the Java lesson too. On wrapper objects, == is a lie detector you didn't ask for. Use .equals(). This bug passes every test with small numbers and breaks in production with large ones, because the cache boundary (-128..127) sits right in the middle of typical test data.

2. Autoboxing has a price — measured, not theorized

"Autoboxing is convenient" is theory. Here's one measured run on my machine — summing 0 to 10,000,000 as a primitive long versus a boxed Long. (Why long? The true sum is 49,999,995,000,000 — far past int's 2.1-billion ceiling. My first draft of this benchmark used Integer and the boxed sum silently overflowed to -2014260032 while the primitive long sum was correct — which is itself a lesson: overflow is silent, and boxing doesn't change it. I switched both sides to long/Long so the comparison is fair.)

public class BoxingPerf {
    static final int N = 10_000_000;   // sum exceeds int range, so we compare long vs Long

    public static void main(String[] args) {
        long t0 = System.nanoTime();
        long sumPrimitive = 0;                      // all primitive long
        for (int i = 0; i < N; i++) {
            sumPrimitive += i;
        }
        long primitiveMs = (System.nanoTime() - t0) / 1_000_000;

        t0 = System.nanoTime();
        Long sumBoxed = 0L;                         // Long: allocate + unbox on every iteration
        for (int i = 0; i < N; i++) {
            sumBoxed += i;
        }
        long boxedMs = (System.nanoTime() - t0) / 1_000_000;

        System.out.println("N = " + N);
        System.out.println("long sum = " + sumPrimitive + ", took " + primitiveMs + " ms");
        System.out.println("Long sum = " + sumBoxed + ", took " + boxedMs + " ms");
    }
}

Real output, one run:

N = 10000000
long sum = 49999995000000, took 34 ms
Long sum = 49999995000000, took 355 ms
Read this like an engineer, not a headline. This is one run on one machine — rerun it and the absolute numbers will move (mine ranged roughly 10x on repeat runs). What won't move is the story: the Long loop allocates ten million Long objects and unboxes every one of them. The primitive loop allocates nothing. For a hot loop, that 10x is real money; for code that runs once per request, it's noise. The rule isn't "never box" — it's "don't box in the hot path," and now you know what boxing actually costs: an allocation per value, every iteration.

3. Generics: the compiler as a bouncer

In Python, a list holds anything and you find out at runtime:

# Python: no declared element type — the bug arrives at runtime
mixed = [1, "two", 3.0, None]
print([type(v).__name__ for v in mixed])   # ['int', 'str', 'float', 'NoneType']

Pre-generics Java had the same shape — a raw List holding anything, with a ClassCastException waiting at runtime. Generics moved that failure to compile time, which is the whole point. Watch both, side by side:

import java.util.ArrayList;
import java.util.List;

public class GenericsDemo {
    public static void main(String[] args) {
        // Raw list (pre-generics style): compiles, fails at runtime
        List raw = new ArrayList();
        raw.add("hello");
        raw.add(42);
        System.out.println("raw list contents: " + raw);
        try {
            String s = (String) raw.get(1);        // ClassCastException at runtime
            System.out.println(s);
        } catch (ClassCastException e) {
            System.out.println("RAW: caught ClassCastException: " + e.getMessage());
        }

        // Generic list: the WRONG ADD refuses to compile (shown commented out)
        List<String> safe = new ArrayList<>();
        safe.add("hello");
        // safe.add(42);  // <-- compile error: incompatible types
        String s = safe.get(0);                     // no cast needed
        System.out.println("generic list: " + safe + ", get(0) = " + s);

        // Generic class + generic method
        Box<String> nameBox = new Box<>("Grace");
        System.out.println("Box holds: " + nameBox.get()
            + " (" + nameBox.get().getClass().getSimpleName() + ")");

        Box<Integer> countBox = new Box<>(42);
        System.out.println("Box holds: " + countBox.get()
            + " (" + countBox.get().getClass().getSimpleName() + ")");

        System.out.println("sum of ints: " + total(List.of(1, 2, 3)));
        System.out.println("sum of doubles: " + total(List.of(1.5, 2.5)));
    }

    /** One generic class instead of a Box class per type. */
    static class Box<T> {
        private final T value;
        Box(T value) { this.value = value; }
        T get() { return value; }
    }

    /** One generic method that sums any list of numbers. */
    static <T extends Number> double total(List<T> nums) {
        double total = 0;
        for (T n : nums) total += n.doubleValue();
        return total;
    }
}

Real output (including the compiler's own honesty note about the raw list — those two Note: lines are real, and they're the compiler telling you raw types are unsafe):

Note: GenericsDemo.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
raw list contents: [hello, 42]
RAW: caught ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
generic list: [hello], get(0) = hello
Box holds: Grace (String)
Box holds: 42 (Integer)
sum of ints: 6.0
sum of doubles: 4.0

Two things to notice. First, the Python parallel: this is what list[int] type hints do in a mypy-checked codebase — declare the element type, catch the wrong add before it ships. The difference is that in Java the compiler always checks; there's no "run mypy if you remember" step. Second, the bouncer works both ways: uncommenting safe.add(42) produces this real error, and the program never runs:

GAdd.java:2: error: incompatible types: int cannot be converted to String
public class GAdd { public static void main(String[] a){ List<String> s = new ArrayList<>(); s.add(42); } }
                                                                                                   ^
1 error
Python-dev principle: generics are mypy that can't be skipped. A Python type hint is a suggestion your interpreter ignores; a Java generic is a contract the compiler enforces. When you feel generics are bureaucratic, remember the raw list above: without them, "hello" and 42 sit in the same list and you meet the ClassCastException at 2 AM instead of at compile time.

Wildcards: <? extends T> vs <? super T> — PECS

The one generics question that haunts everyone: when do you write extends and when super? The rule is PECS: Producer Extends, Consumer Super. If a list produces values for you to read, bound it with ? extends T. If it consumes values you write into it, bound it with ? super T. Here's the canonical worked example — copying apples and oranges into a fruit bowl:

import java.util.ArrayList;
import java.util.List;

public class PecsDemo {
    static class Fruit { public String toString() { return "fruit"; } }
    static class Apple extends Fruit { public String toString() { return "apple"; } }
    static class Orange extends Fruit { public String toString() { return "orange"; } }

    /** Copy everything from src into dst. */
    static <T> void copyAll(List<? extends T> src, List<? super T> dst) {
        for (T item : src) {          // src is a producer: we READ T out of it
            dst.add(item);            // dst is a consumer: we WRITE T into it
        }
    }

    public static void main(String[] args) {
        List<Apple> apples = new ArrayList<>(List.of(new Apple(), new Apple()));
        List<Orange> oranges = new ArrayList<>(List.of(new Orange()));
        List<Fruit> fruitBowl = new ArrayList<>();

        copyAll(apples, fruitBowl);   // List<Apple> is a producer of Fruit
        copyAll(oranges, fruitBowl);  // List<Orange> is a producer of Fruit
        System.out.println("fruitBowl = " + fruitBowl);

        // And it still works for the simple same-type case:
        List<String> names = new ArrayList<>(List.of("a", "b"));
        List<Object> objects = new ArrayList<>();
        copyAll(names, objects);      // List<String> -> List<Object>: ? super Object accepts String
        System.out.println("objects = " + objects);
    }
}

Real output:

fruitBowl = [apple, apple, orange]
objects = [a, b]

Why this signature and not copyAll(List<T> src, List<T> dst)? Because List<Apple> is not a List<Fruit> — generics are invariant. If they weren't, you could add an Orange into what someone else believes is a list of Apple. The wildcard says exactly what you mean: "I read Ts from here" and "I write Ts into here."

And here's what happens when you get the bound wrong — you declare the list a producer (? extends) and then try to write through it. The compiler's error is real and worth reading slowly:

import java.util.ArrayList;
import java.util.List;

public class WildcardErrorDemo {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>();
        names.add("grace");
        // We need to ADD Strings into this list, so it must be a CONSUMER (? super).
        // Declaring it as a producer (? extends) makes add() illegal:
        List<? extends Object> producer = names;
        producer.add("hopper");  // ERROR: nothing can be added through a ? extends reference
    }
}
WildcardErrorDemo.java:11: error: incompatible types: String cannot be converted to CAP#1
        producer.add("hopper");  // ERROR: nothing can be added through a ? extends reference
                     ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object from capture of ? extends Object
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

CAP#1 is the compiler's name for "some specific but unknown subtype of Object." It can't let you add a String because the list might really be a List<SomeOtherSubtype>. That's not pedantry — it's the bouncer protecting the invariant. The mnemonic again: reading → extends, writing → super. If you take one trick from this post, take this one.

4. Type erasure: the generics are a compile-time fiction

Here's the sentence that reframes everything you just learned: at runtime, List<String> and List<Integer> are the same class. The type arguments exist only in the compiler's head. The compiler checks every add and get, then erases the parameters and emits plain List bytecode. Run it and see:

import java.util.ArrayList;
import java.util.List;

public class ErasureDemo {
    public static void main(String[] args) {
        List<String> strings = new ArrayList<>();
        List<Integer> ints = new ArrayList<>();

        System.out.println("strings.getClass() = " + strings.getClass());
        System.out.println("ints.getClass()    = " + ints.getClass());
        System.out.println("same class? " + (strings.getClass() == ints.getClass()));

        // And the raw type check agrees:
        System.out.println("strings instanceof List -> " + (strings instanceof List));
    }
}

Real output:

strings.getClass() = class java.util.ArrayList
ints.getClass()    = class java.util.ArrayList
same class? true
strings instanceof List -> true

Here's the diagram to keep in your head — compile time on the left, runtime on the right:

COMPILE TIME RUNTIME List<String> strings add(42) rejected by compiler List<Integer> ints add("x") rejected by compiler ArrayList the <T> is gone erasure: the compiler checks, then the type arguments evaporate

Erasure is not trivia — it dictates what Java can't do, and the errors prove it. Three hard limits, all real compiler output:

You can't instantiate a type parameter — at runtime T is Object; the compiler has no class to call new on:

ErasureReal.java:4: error: unexpected type
        return new T();
                   ^
  required: class
  found:    type parameter T
  where T is a type-variable:
    T extends Object declared in method <T>make()

You can't instanceof a parameterized type — there'd be nothing to check, since the parameter is gone:

ErasureReal.java:7: error: Object cannot be safely cast to List<String>
        if (o instanceof List<String>) {
            ^
2 errors

You can't overload on the type argument — after erasure both signatures are f(List):

Ovl.java:2: error: name clash: f(List<Integer>) and f(List<String>) have the same erasure
public class Ovl { void f(List<String> a){} void f(List<Integer> a){} }
                                                 ^
1 error
The bridge-method breadcrumb. Erasure has one visible runtime fossil: bridge methods. When a subclass overrides a generic method with a concrete type — StringBox extends Box<String> overriding T get() with String get() — the compiler quietly generates a second, synthetic Object get() that delegates to the String one, so erased callers still link. javap shows the ghost (real output):
class StringBox extends Box<java.lang.String> {
  StringBox(java.lang.String);
  java.lang.String get();
  java.lang.Object get();   // <-- the synthetic bridge method
}
You will almost never write a bridge method yourself. But when a stack trace or a reflection dump shows a method you never wrote, now you know where it came from: erasure, patching the hole it left.
Python-dev principle: generics are the compiler's annotations, not the runtime's memory. Python's list[int] is erased at runtime too — type([]) tells you nothing about the element type either. The difference is only that Java's compiler enforces the annotation before erasing it. When you wonder "why can't I do X with generics?" the answer is nearly always one word: erasure.

5. var: type inference, not dynamic typing

Java 10 added var, and every Python developer reads it as "finally, dynamic typing." It is the opposite: var is the compiler inferring a fixed, static type from the initializer. The type is locked at declaration and never changes:

import java.util.List;

public class VarDemo {
    public static void main(String[] args) {
        var count = 42;                    // inferred as int, locked in forever
        System.out.println("count = " + count);

        var name = "grace";                // inferred as String
        System.out.println("name = " + name.toUpperCase());

        var names = List.of("a", "b");     // inferred as List<String>
        System.out.println("names.size() = " + names.size());

        for (var n : names) {              // n is String — no cast needed
            System.out.println("n.length() = " + n.length());
        }
    }
}

Real output:

count = 42
name = GRACE
names.size() = 2
n.length() = 1
n.length() = 1

And the proof that it's not dynamic typing — assigning a String to the int-inferred count is a compile error, same as if you'd written int:

VarErrorDemo.java:5: error: incompatible types: String cannot be converted to int
        count = "hello";         // ERROR: a String can never be an int, var or not
                ^
1 error
Where var goes and where it doesn't. var is for local variables (and for-loop variables) only — not fields, not parameters, not return types. Your class's public surface still declares every type explicitly; var just spares you from writing HashMap<String, List<Integer>> twice inside a method body. Compare with Python, where the dynamic-typing demo below is perfectly legal:
x = 42;   print(type(x).__name__, "=", x)   # int = 42
x = "hello"; print(type(x).__name__, "=", x)  # str = hello
x = None;  print(type(x).__name__, "=", x)    # NoneType = None
Real Python output: int = 42, str = hello, NoneType = None. In Java, var would freeze x as int on the first line and reject the rest. Same keyword shape, opposite semantics.

6. Nullability: null is not None

Python's None is a well-behaved value: you can pass it around, store it, compare it, and phonebook.get("hopper") returning None is just a fact you branch on. Java's null is the absence of a reference — the moment you touch it (call a method, read a field), the JVM throws NullPointerException. Same "missing value" idea, opposite temperament: None waits politely, null explodes on contact.

The good news first: since JDK 14, NPEs come with helpful null messages that name the exact dereference that was null. This ran on JDK 21:

import java.util.HashMap;
import java.util.Map;

public class NpeDemo {
    record Team(String name, Map<String, String> leadBy) {}

    public static void main(String[] args) {
        Map<String, String> leads = new HashMap<>();
        leads.put("backend", "grace");
        // note: no "frontend" key — map.get returns null
        Team team = new Team("cityops", leads);

        try {
            int len = team.leadBy().get("frontend").length();   // NPE here
            System.out.println(len);
        } catch (NullPointerException e) {
            System.out.println("Caught NullPointerException. Message:");
            System.out.println(e.getMessage());
        }
    }
}

Real output:

Caught NullPointerException. Message:
Cannot invoke "String.length()" because the return value of "java.util.Map.get(Object)" is null

That's genuinely useful — the old message was just null and a stack trace. Now the JVM tells you which link in the chain was null. But diagnosis isn't prevention. The prevention toolkit, in order of preference:

1. Objects.requireNonNull — fail fast, with a name. Validate at the boundary so a null blows up at the parameter that was null, with the parameter's name, instead of three methods later:

Objects.requireNonNull(name, "name must not be null");
// real output when violated: java.lang.NullPointerException: name must not be null

2. Optional — explicit absence, done right. Optional<T> is a container that holds zero or one value. Used correctly, it replaces "check for null, branch, hope" with a small vocabulary: map, flatMap, orElse, ifPresentOrElse. The idiom: return Optional from lookup-style methods, and decide what absence means at the boundary:

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

public class OptionalDemo {
    static Map<String, String> phonebook = new HashMap<>(Map.of("grace", "555-0100"));

    /** RIGHT: Optional for a value that may be absent. */
    static Optional<String> lookup(String name) {
        return Optional.ofNullable(phonebook.get(name));
    }

    /** RIGHT: decide at the boundary, with map/orElse. No get() in sight. */
    static String greeting(String name) {
        return lookup(name)
                .map(num -> "Calling " + name + " at " + num + " ...")
                .orElse("No number for " + name + " - using the front desk.");
    }

    public static void main(String[] args) {
        // requireNonNull: fail fast with a named parameter, not a mystery NPE later
        try {
            Objects.requireNonNull(null, "name must not be null");
        } catch (NullPointerException e) {
            System.out.println("requireNonNull -> " + e.getMessage());
        }

        // The right way
        System.out.println(greeting("grace"));
        System.out.println(greeting("hopper"));

        // The WRONG way: Optional.get() without a check is just NPE with extra steps
        try {
            String num = lookup("hopper").get();   // NoSuchElementException
            System.out.println(num);
        } catch (java.util.NoSuchElementException e) {
            System.out.println("WRONG way -> NoSuchElementException: " + e.getMessage());
        }

        // Optional is not Java's None: orElse / orElseGet / ifPresentOrElse are the vocabulary
        lookup("ada").ifPresentOrElse(
                num -> System.out.println("found: " + num),
                () -> System.out.println("not found, no exception, no null - just a branch"));
    }
}

Real output:

requireNonNull -> name must not be null
Calling grace at 555-0100 ...
No number for hopper - using the front desk.
WRONG way -> NoSuchElementException: No value present
not found, no exception, no null - just a branch

Compare with the Python version, where None is just returned and branched on — no container needed:

def lookup(phonebook, name):
    return phonebook.get(name)          # None when absent, like Optional.empty()

print(lookup(phonebook, "grace"))       # 555-0100
print(lookup(phonebook, "hopper"))      # None — but not an exception
Python-dev principle: Optional is not "Java's None." None is a value you can store, pass, and return freely; Optional is a container with a contract. The idiom: use it as a return type for lookups, never as a field (a field should be null-checked or non-null by construction), never for collections (return an empty list, not Optional<List>), and never call .get() without a check — that's just a NoSuchElementException wearing a trench coat. And note the last trap the demo implies: Optional.of(null) itself throws NullPointerException (real output: java.lang.NullPointerException — note the empty message). The safe constructor for "maybe null" is ofNullable.

7. Records: Java's answer to dataclasses

You know Python's @dataclass — a compact way to declare "this class is just data":

from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int

p1 = Point(3, 4); p2 = Point(3, 4)
print("repr:", p1)        # repr: Point(x=3, y=4)
print("equals:", p1 == p2)  # equals: True

Java's record (stable since 16) is the same idea, enforced by the language. One line declares the components; the compiler generates the accessor methods, equals, hashCode, and toString. To feel how much it saves, here's the record next to the equivalent hand-written class:

import java.util.Objects;

public class RecordDemo {
    /** One line: accessor, equals, hashCode, toString — all generated. */
    record Point(int x, int y) {}

    /** The equivalent class, written out by hand. */
    static final class PointClass {
        private final int x;
        private final int y;
        PointClass(int x, int y) { this.x = x; this.y = y; }
        int x() { return x; }
        int y() { return y; }

        @Override public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof PointClass p)) return false;
            return x == p.x && y == p.y;
        }

        @Override public int hashCode() { return Objects.hash(x, y); }

        @Override public String toString() { return "PointClass[x=" + x + ", y=" + y + "]"; }
    }

    public static void main(String[] args) {
        Point p1 = new Point(3, 4);
        Point p2 = new Point(3, 4);
        System.out.println("record toString: " + p1);
        System.out.println("record equals:   " + p1.equals(p2));
        System.out.println("record hashCode: " + p1.hashCode() + " == " + p2.hashCode()
                + " -> " + (p1.hashCode() == p2.hashCode()));

        PointClass c1 = new PointClass(3, 4);
        PointClass c2 = new PointClass(3, 4);
        System.out.println("class  toString: " + c1);
        System.out.println("class  equals:   " + c1.equals(c2));

        // Accessors: p.x() — no getX()
        System.out.println("record accessor: p1.x() = " + p1.x());
    }
}

Real output:

record toString: Point[x=3, y=4]
record equals:   true
record hashCode: 97 == 97 -> true
class  toString: PointClass[x=3, y=4]
class  equals:   true
record accessor: p1.x() = 3

One line versus thirty, identical behavior — including hashCode consistency (equal objects, equal hashes: 97 == 97, so records are safe as map keys out of the box). Three differences from dataclasses worth internalizing:

  • Records are implicitly final and shallowly immutable. All fields are final; a record can't extend another class (it implicitly extends java.lang.Record). A dataclass is mutable by default unless you pass frozen=True — the record starts from the stricter end, which matches post 1's immutability theme.
  • Accessors are named after the component — p.x(), not p.getX(). This trips up every Java veteran and Python convert alike; your IDE will autocomplete getX() and it won't exist.
  • Records can still have behavior. You can add methods and even validate in a compact constructor (Point { if (x < 0) throw ...; }). They're "data-first," not "data-only."
Record vs class — the decision rule. Reach for a record when the thing is its data: DTOs, API request/response shapes, config snapshots, map keys, tuple-like return values. Reach for a class when it needs mutable state, inheritance, or behavior that owns the data. If you find yourself writing a class whose fields are all final and whose methods are getters plus equals/hashCode/toString — that's a record wearing a disguise.

8. Pattern matching instanceof — and a sealed-class teaser

The pre-generics ClassCastException in section 3 needed a cast after the instanceof check. Pattern matching merges the two: check the type and bind a variable of that type in one step, no cast:

public class PatternMatchDemo {
    public static void main(String[] args) {
        Object[] values = {42, "hello", 3.14, true, null};

        for (Object v : values) {
            // The classic way (still valid): cast after instanceof
            if (v instanceof Integer) {
                Integer i = (Integer) v;
                System.out.println("classic: Integer " + i + ", squared = " + (i * i));
            }

            // Pattern matching: no cast, the binding flows into the if body
            if (v instanceof Integer i) {
                System.out.println("pattern: Integer " + i + ", squared = " + (i * i));
            } else if (v instanceof String s && !s.isEmpty()) {
                System.out.println("pattern: String of length " + s.length());
            } else if (v == null) {
                System.out.println("pattern: null is not an instance of anything");
            } else {
                System.out.println("pattern: something else (" + v.getClass().getSimpleName() + ")");
            }
        }

        // instanceof is also false for null — the binding just never happens
        Object nothing = null;
        System.out.println("null instanceof String -> " + (nothing instanceof String));
    }
}

Real output:

classic: Integer 42, squared = 1764
pattern: Integer 42, squared = 1764
pattern: String of length 5
pattern: something else (Double)
pattern: something else (Boolean)
pattern: null is not an instance of anything
null instanceof String -> false

(Yes, both branches fire for 42 — the demo runs the classic and the pattern version on purpose, so you can compare. The pattern version is strictly less code with no cast to get wrong.)

Teaser: sealed classes. If pattern matching is "ask what type this is," sealed classes are "declare the complete list of possible types up front": sealed interface Shape permits Circle, Square { }. A sealed hierarchy is closed — no surprise subclasses — which means a switch with patterns over it can be checked for exhaustiveness at compile time: handle every permitted type and the compiler proves you didn't miss one. That's the direction modern Java is walking — closed types + pattern matching — and post 4's ecosystem tour will show you where Spring and the standard library already lean on it.

9. The whole map: Python typing → Java

One table to bookmark. The left column is the Python concept you already know; the right is the Java equivalent you now understand, with the section to revisit:

PythonJavaThe difference that matters
Dynamic typing — a name holds anythingStatic typing — every variable, parameter, field declares its type§1–5: the type is fixed at declaration, checked by the compiler, enforced always
int, float, bool (objects, arbitrary precision)Primitives (int, long, double, boolean…) + wrapper classes (Integer, Long…)§1: fixed sizes, silent overflow, and autoboxing bridging the two worlds
Type hints: list[int], dict[str, int]Generics: List<Integer>, Map<String, Integer>§3: Java's are enforced by the compiler, never optional; then erased at runtime (§4)
typing.TypeVar("T")Type parameter <T> on classes and methods§3: same "write once, work for many types" idea, with bounds (<T extends Number>)
list[int] | list[str] (unions)Wildcards: List<? extends Fruit>, List<? super T>§3: PECS — reading → extends, writing → super
Optional[X] / X | NoneOptional<T>§6: a container, not a value — return it from lookups, map/orElse at the boundary, never .get() unchecked
Nonenull§6: None is a value you branch on; null throws NPE on contact — with helpful messages since JDK 14
@dataclass / NamedTuplerecord§7: one line, equals/hashCode/toString generated, implicitly final; accessors are x() not getX()
Protocol (structural / duck typing)interface (nominal typing)A Java class must declare implements — matching method shapes isn't enough. (Post 4 touches the ecosystem side of this.)
@overload (type-checker fiction)Method overloading (real dispatch)Java picks the overload at compile time by argument types — but not by generic type argument (§4: same erasure!)
isinstance(x, int) + cast-by-conventioninstanceof with pattern binding§8: if (v instanceof Integer i) — no cast, and null is never an instance of anything
The post in one line: Java's type system is a proofreader, not a prison. Primitives and wrappers are the two worlds you shuttle between; autoboxing is convenient but costs an allocation per value; generics are mypy that can't be skipped — enforced, then erased; var infers a fixed type, it doesn't loosen one; null explodes where None waits, so Optional carries absence explicitly; records are dataclasses with the compiler doing the boilerplate. Every one of these exists to move a class of bug from 2 AM to compile time. Let the compiler be the bouncer — your job is to write the guest list.

Field check: earn the type system yourself

Reading this post rents you the ideas; running these buys them. Do all four on your own machine (JDK 21, plain javac/java, no dependencies):

  1. Extend the cache trap. Write a program that compares Integer.valueOf(n) == Integer.valueOf(n) for n from 120 to 135 and prints the results. Mark the exact boundary where true flips to false, then predict — before running — what Long.valueOf(127) == Long.valueOf(127) gives. If you were wrong, find the cache rule for Long and explain it.
  2. Break the bound on purpose. Take the PECS copyAll method and deliberately flip the wildcards (? super T on the source, ? extends T on the destination). Read the real javac errors, then explain in one sentence each why the compiler rejects the src read and the dst write.
  3. Benchmark your own machine. Run the BoxingPerf program from section 2 three times and record the long vs Long timings. Then change the loop to accumulate into a long while reading from a List<Long> — how much of the cost is the unboxing on read versus the allocation on write? Write down your hypothesis before you run.
  4. Record your dataclass. Pick the most dataclass-like class in your Python codebase (or write a small Order/Customer pair), port it to a Java record with a compact-constructor validation (reject a blank name or negative price), and print toString, equals, and hashCode for two equal instances. Then deliberately try to make the record extend a class, and read the compiler's refusal.

What's next

You now think in Java's type system: two worlds of values, generics as an enforced contract, erasure as the reason behind the limits, absence handled explicitly, and records for data. The last bridge post zooms out from the language to the world around it — the ecosystem you'll actually work in:

Next: The JVM Ecosystem: Maven Central, Spring, and What's Standard

And the full map of everything published so far — every post, every track, in order:

Continue: Java Learning Roadmap 2026

Comments

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number