概要
業務コードでは「この型は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 で制限し、フォーマット追加時に対応漏れの箇所をコンパイルエラーで洗い出す
コード例
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));
}
}Version Coverage
sealed interface + record が使える。instanceof パターンマッチングで if-else 分岐は簡潔に書けるが、switch 式での網羅性チェックはプレビュー段階。
// 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
注意点
sealed interface の permits に列挙するクラスは、同一パッケージ(または同一モジュール)内に存在する必要がある
Java 17 の switch 式では sealed 型の網羅性チェックがまだプレビューのため、default 句が必要になる場合がある。Java 21 で正式対応
sealed を使うと外部からの拡張が不可能になる。ライブラリとして公開する型に sealed を付けるかどうかは、拡張ポイントの設計方針を先に決めること
record の入れ子定義(sealed interface の permits に内部 record を列挙)は、完全修飾名が長くなりやすい。トップレベルに置くかどうかは可読性とのバランスで判断する
sealed の permits にクラスを追加し忘れると、そのクラスはコンパイルエラーになる。これは意図した挙動だが、初見では原因に気付きにくい
FAQ
record は class を extends できないため、record をバリアントに使う場合は interface 一択です。class をバリアントにする場合でも、多重実装の柔軟性から interface を基本に据えるのが一般的です。
permits にクラスを追加し、既存の switch 式にそのケースがなければコンパイルエラーになります。これが sealed の最大の利点で、分岐漏れを実行前に発見できます。
目的が異なります。enum は固定のシングルトン定数セットで、sealed は型階層の制限です。enum の各値はインスタンスが1つですが、sealed record のバリアントは任意の値で複数インスタンスを作れます。