Just added: Algorithms you can run and practice
Updated Jun 22, 2026 Edit on GitHub

Records, Sealed Types & Modern Java - Less Boilerplate, More Safety

Java has a reputation: a language where a simple thing takes fifteen lines. Back in Phase 5 you wrote an Account class with private fields, a constructor, getters, and a hand-written toString/equals/hashCode trio - for one data type. Multiply that across a real codebase and "ceremony" starts to feel like an insult.

Modern Java is quieter and sharper than its reputation. A whole wave of features exists for one reason: let you say the common thing concisely, and let the compiler catch mistakes it used to wave through. The mental model for this phase: take a tired, verbose pattern and replace it with a tight, safer one - repeated five times.

Records - the data class in one line

📝 A record is an immutable data carrier: a class whose entire job is to hold a few values. You declare the fields it carries, and the compiler generates everything else - the constructor, an accessor per field, and correct equals, hashCode, and toString.

Here's roughly the Account-class amount of code for a simple two-value point, the old way:

public final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() { return x; }
    public int y() { return y; }

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

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

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

What just happened: Thirty-odd lines, none interesting - just the mechanical bookkeeping the Phase 5 trio warned you to get right. Worse, every line risks a bug: forget a field in equals, mismatch hashCode, and your objects misbehave in a HashSet.

The same type as a record:

public record Point(int x, int y) {}

What just happened: That one line generates all of the above - a constructor taking x and y, accessors x()/y(), and correct equals/hashCode/toString covering both fields. The fields are private final, so a Point is immutable. (Accessors use the field name directly, not getX()/getY().)

public class Main {
    public static void main(String[] args) {
        Point a = new Point(1, 2);
        Point b = new Point(1, 2);

        System.out.println(a);            // generated toString
        System.out.println(a.x());        // generated accessor
        System.out.println(a.equals(b));  // generated value equality
    }
}
$ java Main.java
Point[x=1, y=2]
1
true

What just happened: Printing a gave a readable Point[x=1, y=2] for free, a.x() read the first field, and a.equals(b) returned true because the generated equals compares values - the thing you had to hand-write before. Two separate objects with the same data are equal, as a value type should be.

💡 When to reach for a record. Use one whenever the type's purpose is to carry data: DTOs (a JSON request shape, a query row), value objects (Money, Coordinate), or a method returning two things at once. A class that's all fields and getters almost certainly wants to be a record.

⚠️ Need validation? Use a compact constructor. A record isn't a dumb tuple. Write a compact constructor (the record name, no parameter list) to validate before the fields are assigned:

public record Point(int x, int y) {
    public Point {                        // compact constructor - no parameters listed
        if (x < 0 || y < 0) {
            throw new IllegalArgumentException("coordinates must be non-negative");
        }
        // no explicit assignment needed - the fields are set for you afterward
    }
}

What just happened: The compact constructor runs your check, then Java assigns x and y automatically - you don't write this.x = x. Now new Point(-1, 0) throws instead of quietly creating a nonsense point, so a record stays immutable and always valid.

Optional - a box that might be empty, instead of null

You met null and its favorite crime, NullPointerException, back in Phase 7. The modern reply to "this might not have a value" is Optional - and the point isn't magic, it's being upfront about it.

Optional<User> is a small wrapper holding either one User or nothing. A method returning Optional<User> tells callers, right in the signature, "this might come back empty - deal with it." Compare a bare User that's sometimes null: nothing warns you, you forget the check, and the NPE finds you in production.

import java.util.Optional;

public class Users {
    // Old way: returns User, or null if not found - the caller can't tell.
    // New way: the Optional makes "might be missing" part of the contract.
    static Optional<String> findName(int id) {
        if (id == 1) return Optional.of("Ada");
        return Optional.empty();          // plain "nothing here"
    }

    public static void main(String[] args) {
        String found   = findName(1).map(String::toUpperCase).orElse("(unknown)");
        String missing = findName(99).map(String::toUpperCase).orElse("(unknown)");

        System.out.println(found);
        System.out.println(missing);

        findName(1).ifPresent(name -> System.out.println("present: " + name));
    }
}
$ java Users.java
ADA
(unknown)
present: Ada

What just happened: findName returns an Optional<String> instead of a possibly-null string. map transforms the value if present, orElse supplies a fallback, ifPresent runs code only when a value exists. The id-99 lookup flowed through with no explicit if (x == null) and no risk of an NPE.

⚠️ Don't over-Optional. It's for return values that may legitimately be absent - not for fields (it bloats objects and breaks serialization) and not for method parameters (overload the method or accept the plain type instead). Never call .get() without checking - that's null with extra steps. Use Optional to make a missing result impossible to ignore; don't sprinkle it everywhere.

Switch expressions - switch that returns a value

The old C-style switch was a minefield: a colon per case, a break you had to remember or fall through by accident, and no way to produce a value directly. Modern Java turns switch into an expression that hands back a value, with no fall-through.

A switch expression uses the arrow form case X -> result;, evaluates to a value you assign directly, and is exhaustive - for an enum, the compiler checks you've covered every case. No break; each arrow handles one case. A branch needing more than one line wraps in braces and uses yield.

public class Main {
    enum Day { MON, TUE, WED, THU, FRI, SAT, SUN }

    static String kind(Day day) {
        return switch (day) {                       // switch as an expression
            case SAT, SUN -> "weekend";             // group cases with a comma
            case MON, TUE, WED, THU, FRI -> {       // a block branch...
                String note = "five of these";
                yield "weekday (" + note + ")";     // ...uses yield to produce its value
            }
        };
    }

    public static void main(String[] args) {
        System.out.println(kind(Day.SAT));
        System.out.println(kind(Day.MON));
    }
}
$ java Main.java
weekend
weekday (five of these)

What just happened: The whole switch evaluated to a string returned directly - no temp variable, no break, no fall-through. SAT, SUN were grouped on one arrow; the multi-line branch used yield to produce its value. Covering every Day lets the compiler accept it with no default; leave a case out and it refuses to compile - a missing case is an error, not a silent runtime bug.

Pattern matching - test the type and bind in one move

A tired old ritual: check a type with instanceof, then cast to that exact type on the next line to use it. Two lines saying the same thing, and a chance to typo the cast. Pattern matching fuses them.

if (obj instanceof String s) tests and, when the test passes, binds the value to a new variable s of that type - already cast, ready to use. The same pattern works inside switch, and can even deconstruct a record into its components.

public class Main {
    static String describe(Object obj) {
        // Old way: if (obj instanceof String) { String s = (String) obj; ... }
        if (obj instanceof String s) {
            return "string of length " + s.length();   // s is already a String
        }
        return switch (obj) {
            case Integer i -> "int doubled = " + (i * 2);  // i is bound as an Integer
            case Point(int x, int y) -> "point at " + x + "," + y;  // record deconstruction
            default -> "something else";
        };
    }

    record Point(int x, int y) {}

    public static void main(String[] args) {
        System.out.println(describe("hello"));
        System.out.println(describe(21));
        System.out.println(describe(new Point(3, 4)));
        System.out.println(describe(3.14));
    }
}
$ java Main.java
string of length 5
int doubled = 42
point at 3,4
something else

What just happened: obj instanceof String s checked the type and bound s in one step, so s.length() worked immediately - no separate cast. Inside the switch, case Integer i -> did the same per branch, and case Point(int x, int y) -> went further: it matched a Point and pulled its two components straight into x and y. That last move - record deconstruction - is where records and pattern matching team up.

Sealed types - a fixed, compiler-checked set of cases

Sometimes a type needs a known, fixed set of implementations - a Shape is a Circle or a Square, nothing else, ever. Plain interfaces can't express that: anyone can write a new implementor, so the compiler can never be sure a switch handled them all. Sealed types fix this.

📝 A sealed type uses sealed ... permits to name the only types allowed to extend or implement it: sealed interface Shape permits Circle, Square. Nobody outside that list can join the family. The payoff: switch over it with patterns - since the compiler knows the complete set, it can verify the switch is exhaustive, with no default needed. Miss a case and you get a compile error, not a runtime surprise.

public class Main {
    sealed interface Shape permits Circle, Square {}
    record Circle(double radius) implements Shape {}
    record Square(double side) implements Shape {}

    static double area(Shape s) {
        return switch (s) {
            case Circle(double r) -> Math.PI * r * r;   // deconstruct the record
            case Square(double side) -> side * side;
            // no default - the compiler knows Circle and Square are ALL the cases
        };
    }

    public static void main(String[] args) {
        System.out.printf("%.2f%n", area(new Circle(2)));
        System.out.printf("%.2f%n", area(new Square(3)));
    }
}
$ java Main.java
12.57
9.00

What just happened: Shape permits exactly Circle and Square, so the switch covering both is provably complete - satisfied without a default. Add a Triangle to permits six months from now, and every switch over Shape that missed it stops compiling, pointing at each place needing an update. The compiler becomes your checklist instead of a bug report.

💡 Sealed + records + switch = a "one of a fixed set" type. Together these give Java what other languages call discriminated unions or sum types: a value that's exactly one of a closed list of shapes, each carrying its own data, handled by a switch the compiler guarantees is total - the cleanest way to model "a result is either a Success(value) or a Failure(reason)," with exhaustiveness checking free.

Recap

  1. A record (record Point(int x, int y) {}) collapses a whole data class - constructor, accessors, equals/hashCode/toString - into one immutable line. Use it for DTOs and value objects; add a compact constructor for validation.
  2. Optional<T> makes "might be absent" part of a method's return type, so callers can't forget the case. Use map/orElse/ifPresent; ⚠️ don't put it on fields or parameters.
  3. Switch expressions return a value with the case X -> ... arrow form - no break, no fall-through, yield for multi-line branches, exhaustiveness checked for enums and sealed types.
  4. Pattern matching fuses the type test and the cast: if (obj instanceof String s) binds s ready to use, works in switch (case Integer i ->), and can deconstruct records (case Point(int x, int y)).
  5. Sealed types (sealed interface Shape permits Circle, Square) fix the set of implementations, letting the compiler prove a pattern switch is exhaustive - a missing case is a compile error.
  6. Together - sealed + records + switch - these are Java's answer to discriminated unions: "one of a fixed set," handled totally, checked by the compiler.

You now write Java the way modern Java wants to be written: less ceremony, more meaning, and a compiler that catches mistakes the old style let slip. Next: leaving single-threaded code behind for the hard, fascinating world of doing several things at once.

Quick check

Test yourself on the ideas that separate old Java from modern Java:

[
  {
    "q": "What does `public record Point(int x, int y) {}` generate for you?",
    "choices": [
      "A constructor, accessors `x()` and `y()`, and correct `equals`, `hashCode`, and `toString` - for an immutable type",
      "Only an empty class with two public mutable fields",
      "Getters named `getX()` and `getY()`, but no `equals` or `hashCode`",
      "Nothing - `record` is just a comment-style keyword"
    ],
    "answer": 0,
    "explain": "A record is an immutable data carrier. The compiler generates the canonical constructor, an accessor per field (named after the field, like `x()`), and value-based `equals`/`hashCode`/`toString` - replacing the ~40 lines you'd otherwise hand-write."
  },
  {
    "q": "Why can a pattern-matching `switch` over a sealed type skip the `default` branch?",
    "choices": [
      "Because `sealed ... permits` fixes the complete set of subtypes, so the compiler can verify every case is covered",
      "Because switch expressions never require a default under any circumstances",
      "Because `default` is forbidden inside any switch expression",
      "Because sealed types disable compile-time checking entirely"
    ],
    "answer": 0,
    "explain": "A sealed type names all its permitted implementations, so the compiler knows the full set of cases. When your switch covers them all, it's provably exhaustive - no `default` needed, and missing a case becomes a compile error."
  },
  {
    "q": "What is the right use of `Optional<User>`?",
    "choices": [
      "As a method return type that signals the result may legitimately be absent, forcing callers to handle the empty case",
      "As the type of every field in a class, to avoid ever storing null",
      "As a method parameter type so callers can pass nothing",
      "As a faster replacement for a regular `User` object"
    ],
    "answer": 0,
    "explain": "`Optional` exists to make 'this might be missing' explicit in a return type, so callers can't silently forget the check. It's not meant for fields or parameters - using it there adds overhead and awkwardness without the payoff."
  }
]

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. What does `public record Point(int x, int y) {}` generate for you?

2. Why can a pattern-matching `switch` over a sealed type skip the `default` branch?

3. What is the right use of `Optional<User>`?