概要

業務コードでは「この型は3種類しかない」「このインターフェースを実装できるクラスを限定したい」という場面がしばしばあります。Java 17 で正式導入された sealed interface は、サブタイプを permits で明示的に制限し、想定外の実装クラスの追加をコンパイル時に防ぐ仕組みです。これを record と組み合わせると、各バリアントが不変の値オブジェクトとして定義され、switch 式のパターンマッチングで全ケースの網羅がコンパイラによって保証されます。この記事では、図形計算を題材に sealed + record の基本構文を押さえたうえで、Java 8 で同等の設計をどう表現していたか、Java 21 で switch パターンマッチングがどう改善されたかを比較します。分岐漏れをテストではなくコンパイルで検出できる設計手法として、実務での適用ポイントを整理します。

使いどころ

決済手段(クレジットカード・銀行振込・電子マネー)を sealed interface で定義し、手数料計算の分岐漏れをコンパイル時に検出する

通知チャネル(メール・SMS・プッシュ通知)を sealed record で表現し、送信処理の switch 文で全チャネルの網羅を保証する

帳票の出力形式(PDF・CSV・Excel)を sealed で制限し、フォーマット追加時に対応漏れの箇所をコンパイルエラーで洗い出す

コード例

SealedRecordDemo.java
public class SealedRecordDemo {

    // sealed interface で図形の種類を制限
    sealed interface Shape permits Circle, Rectangle, Triangle {}

    record Circle(double radius) implements Shape {
        double area() { return Math.PI * radius * radius; }
    }

    record Rectangle(double width, double height) implements Shape {
        double area() { return width * height; }
    }

    record Triangle(double base, double height) implements Shape {
        double area() { return 0.5 * base * height; }
    }

    // instanceof パターンマッチングで型安全に分岐(Java 16+)
    static String describe(Shape shape) {
        if (shape instanceof Circle c) {
            return "円形 半径=" + c.radius()
                + " 面積=" + String.format("%.2f", c.area());
        } else if (shape instanceof Rectangle r) {
            return "長方形 " + r.width() + "x" + r.height()
                + " 面積=" + String.format("%.2f", r.area());
        } else if (shape instanceof Triangle t) {
            return "三角形 底辺=" + t.base()
                + " 面積=" + String.format("%.2f", t.area());
        }
        return "不明な図形";
    }

    public static void main(String[] args) {
        Shape[] shapes = {
            new Circle(5.0),
            new Rectangle(3.0, 4.0),
            new Triangle(6.0, 8.0)
        };

        System.out.println("=== sealed interface + record ===");
        for (var shape : shapes) {
            System.out.println(describe(shape));
        }

        // instanceof でフィールドを直接取り出す
        System.out.println("\n=== パターンマッチングによる分解 ===");
        Shape s = new Circle(10.0);
        if (s instanceof Circle c) {
            System.out.println("半径: " + c.radius());
            System.out.println("面積: " + String.format("%.2f", c.area()));
        }

        // record の equals / toString は自動生成
        System.out.println("\n=== record の自動生成メソッド ===");
        var r1 = new Rectangle(3.0, 4.0);
        var r2 = new Rectangle(3.0, 4.0);
        System.out.println("r1: " + r1);
        System.out.println("r1.equals(r2): " + r1.equals(r2));
    }
}

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

sealed interface + record が使える。instanceof パターンマッチングで if-else 分岐は簡潔に書けるが、switch 式での網羅性チェックはプレビュー段階。

Java 17
// Java 17: sealed interface + record
sealed interface Shape permits Circle, Rect, Tri {}
record Circle(double radius) implements Shape {}
record Rect(double w, double h) implements Shape {}
record Tri(double base, double h) implements Shape {}

// instanceof パターンマッチングで分岐
static String describe(Shape s) {
    if (s instanceof Circle c) return "円: r=" + c.radius();
    if (s instanceof Rect r) return "長方形: " + r.w() + "x" + r.h();
    if (s instanceof Tri t) return "三角: base=" + t.base();
    return "不明"; // コンパイラの網羅保証はまだない
}

Library Comparison

標準 API(sealed + record + switch)Java 17 以上でバリアントが有限な型階層を設計するとき。コンパイラの網羅性チェックが最大の利点。Java 21 未満では switch の網羅性チェックがプレビューのため、default 句が必要になる場合がある。
Visitor パターン(従来の Java)Java 8 環境で型ごとの処理分岐を安全に行いたいとき。新しい型の追加時に accept メソッドの実装漏れをコンパイルで検出できる。Visitor インターフェースと accept メソッドのボイラープレートが多い。sealed + switch で同じ安全性がより簡潔に得られる。
Vavr(io.vavr)Java 8 環境で代数的データ型(ADT)を関数型スタイルで扱いたいとき。学習コストが高く、チーム全体での採用には合意が必要。Java 17 以降では sealed + record が標準で同等の表現力を持つ。

注意点

sealed interface の permits に列挙するクラスは、同一パッケージ(または同一モジュール)内に存在する必要がある

Java 17 の switch 式では sealed 型の網羅性チェックがまだプレビューのため、default 句が必要になる場合がある。Java 21 で正式対応

sealed を使うと外部からの拡張が不可能になる。ライブラリとして公開する型に sealed を付けるかどうかは、拡張ポイントの設計方針を先に決めること

record の入れ子定義(sealed interface の permits に内部 record を列挙)は、完全修飾名が長くなりやすい。トップレベルに置くかどうかは可読性とのバランスで判断する

sealed の permits にクラスを追加し忘れると、そのクラスはコンパイルエラーになる。これは意図した挙動だが、初見では原因に気付きにくい

FAQ

sealed interface と abstract class のどちらを使うべきですか。

record は class を extends できないため、record をバリアントに使う場合は interface 一択です。class をバリアントにする場合でも、多重実装の柔軟性から interface を基本に据えるのが一般的です。

sealed 型に新しいバリアントを追加するとどうなりますか。

permits にクラスを追加し、既存の switch 式にそのケースがなければコンパイルエラーになります。これが sealed の最大の利点で、分岐漏れを実行前に発見できます。

sealed は enum の上位互換と考えてよいですか。

目的が異なります。enum は固定のシングルトン定数セットで、sealed は型階層の制限です。enum の各値はインスタンスが1つですが、sealed record のバリアントは任意の値で複数インスタンスを作れます。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます