シールクラスは、継承してよいクラスをpermitsで名指しし、それ以外の継承をコンパイルエラーにするJavaの仕組みです。Java 17で正式機能になり、Java 21のswitchパターンマッチと組み合わせると「全部の型を処理したか」をコンパイラが検査します。この記事では書き方と3つの修飾子に加え、実際に出るコンパイルエラーの文言、再コンパイルを忘れたときに実行時に起きる例外までを、Eclipse Temurin 25.0.4.1でjavacとjavaを実行した結果で示します。
まとめ:シールクラスの要点
- 書き方:親に
sealedを付け、permitsで継承を許すクラスを列挙する。同じファイルに子を書くならpermitsは省略できる。 - 子の修飾子:許可された子は
final・sealed・non-sealedのどれかが必須。recordとenumは書かなくてよい。 - 置き場所:親と子は同じモジュール、名前なしモジュールなら同じパッケージに置く。
- 網羅チェック:Java 21以降で、抽象シールクラスまたはシールインターフェースを対象とする
switchでは、全サブタイプをcaseに書けばdefaultが要らず、型を足し忘れるとコンパイルが止まる。non-sealedの子も、その子の型のcase1つで子孫ごと扱える。 - 落とし穴:
switch側を再コンパイルせずに型を足すと実行時にMatchException。defaultを書くと追加した型が黙ってそこへ流れる。 - 向かない場面:第三者に継承させる拡張点。公開ライブラリでは、許可サブタイプの追加が利用者の
switchを壊す互換性問題になる。
シールクラス(sealed class)とは:継承できる型をpermitsで閉じる仕組み
通常のクラスは、finalでなく、クラスとコンストラクタにアクセスできれば、作者の知らないところでも継承できます。シールクラスはこれを逆にし、「直接継承してよいのはこのクラスだけ」と作者が宣言します。final(継承を全面禁止)と通常のクラス(無制限に開放)の中間にあたる選択肢です。
直接のサブタイプの集合が限定されるため、コンパイラはその集合を前提に検査できます。ただし、non-sealedの枝では、その先のサブタイプまで限定されません。後述のswitch網羅チェックや、ありえないキャストの検出がそれです。仕様の一次情報はJEP 409: Sealed ClassesとJava言語仕様 8.1.1.2です。
Java 15のプレビューからJava 17の正式化まで
JEP 360でJDK 15にプレビューとして入り、JEP 397(JDK 16)の第2プレビューを経て、JEP 409でJDK 17の正式機能になりました。JDK 25のjavacに--release 16を指定するとシール・クラスは-source 16でサポートされていませんで止まり、--release 17なら通ります。
ただしswitchのパターンマッチはJEP 441でJDK 21の正式機能です。--release 17のままパターン付きのcaseを書くとswitch文のパターンは-source 17でサポートされていませんになります。シールクラスの網羅チェックまで使うなら、実質の下限はJava 21です。版ごとの違いはJava 21と25の違い|正式化した新機能・サポート期限・移行で止まる箇所を実機で比較にまとめています。
sealedとpermitsの書き方
次のVehicleはCarとTruckにだけ直接の継承を許します。コードはVehicle.javaとして保存し、javac Vehicle.javaでコンパイルできます。
public abstract sealed class Vehicle permits Car, Truck { }
final class Car extends Vehicle { }
non-sealed class Truck extends Vehicle { }
class BigTruck extends Truck { } // Truckはnon-sealedなので継承できる
インターフェースにもsealedを付けられ、その場合permitsには実装クラスと子インターフェースを並べます。sealed・permits・non-sealedは特定の位置でだけキーワードになる語なので、int sealed = 1, permits = 2;のような既存の変数名もJDK 25でそのままコンパイルできます。
許可サブクラスに付ける3修飾子(final・sealed・non-sealed)
permitsに挙げた子は、階層をその先どうするかを次の3つのどれかで必ず宣言します。finalだけと説明する解説がありますが、3つのどれでもかまいません。
| 修飾子 | 意味 | その子をさらに継承 |
|---|---|---|
final |
ここで打ち止め | 不可 |
sealed |
さらに相手を限定して許可 | 自分のpermitsに挙げた型だけ |
non-sealed |
この枝だけ通常クラスに戻す | 誰でも可 |
どれも付けないとsealed、non-sealedまたはfinal修飾子が必要です(英語環境ではsealed, non-sealed or final modifiers expected)になります。non-sealedは親が子に強制できない設計で、JEP 409も「シールクラスは許可サブクラスがこれを選ぶことを防げない」と書いています。なお、子がクラスでなくインターフェースの場合はfinalを付けられないので、Java言語仕様 9.1.1.4のとおりsealedかnon-sealedの2択になります。
recordとenumを許可サブタイプにする場合
recordは暗黙にfinalなので修飾子は不要です。enumもそのままsealed interfaceを実装できます。次のコードはJDK 25で修飾子なしのままコンパイルが通り、switchではenumの定数をcase Unit.POINTのように個別に書いても網羅と判定されました。
sealed interface Shape permits Circle, Square, Unit { }
record Circle(double r) implements Shape { }
record Square(double s) implements Shape { }
enum Unit implements Shape { POINT, EMPTY }
recordの書き方そのものはJavaのレコードクラス(record)とは?使い方・コンストラクタ・継承できない理由とエラー例をJava 25で実測で扱っています。
permitsを省略できる条件と置き場所の制約
許可したい子をすべて同じソースファイルに書くなら、permitsは省略できます。コンパイラがそのファイル内で親を継承しているクラスを許可対象とみなします(Oracleの日本語ガイドシール・クラスにも同じ説明があります)。別ファイルに分けるならpermitsで列挙します。
置き場所には制約があります。親が名前付きモジュールにあれば子は同じモジュールに、名前なしモジュール(module-info.javaを使わない普通のクラスパス構成)なら同じパッケージに置きます。パッケージaの親がb.Carを許可しようとすると、名前のないモジュールのクラスVehicleは別のパッケージのシール・クラスを拡張できませんで止まりました。パッケージをまたいで子を置きたいなら、module-info.javaで名前付きモジュールにする必要があります。
コンパイルエラーになる書き方と実際のメッセージ(JDK 25)
シールクラスの規則違反は、どれもコンパイル時に止まります。javacの表示言語はOSのロケールに従うので、日本語と英語の両方を載せます(英語はjavac -J-Duser.language=enで出したもの)。検索するときはこの文言で探すと早く見つかります。
- 子に修飾子を付けていない:
sealed、non-sealedまたはfinal修飾子が必要です/sealed, non-sealed or final modifiers expected - permitsに無いクラスが継承した:
クラスはシール・クラスVehicleを拡張できません('permits'句に指定されていないためです)/class is not allowed to extend sealed class: Vehicle (as it is not listed in its 'permits' clause) - permitsに挙げたクラスが継承していない:
無効なpermits句(補足にクラスTruckはシール・クラスを拡張する必要があります) /invalid permits clause - メソッド内のローカルクラスが継承した:
ローカルクラスはシール・クラスを拡張できません/local classes must not extend sealed classes - 匿名クラスで継承した:
匿名クラスはシール・クラスを拡張できません/anonymous classes must not extend sealed classes - switchで型が漏れている:
switch式がすべての可能な入力値をカバーしていません/the switch expression does not cover all possible input values
匿名クラスが使えないのは見落としやすい点です。テストでnew Vehicle() { }のようにその場でスタブを作る書き方ができないため、テスト用の実装もpermitsに含めるか、許可サブクラスのどれかをnon-sealedにして継承の余地を残すかを設計時に決めます。
継承・final・interfaceとの違い
通常の継承・finalとの選び方
判断基準は「サブタイプの集合を作者が握るかどうか」です。利用者に自由な拡張を求めるなら通常のクラスやインターフェース(考え方は継承とは?ポリモーフィズムとの違い・委譲との使い分けをJavaとPythonのコードで解説)、継承をまったくさせないならfinal、種類は複数あるが有限に固定したいならシールクラスです。許可する子が1つも無いならfinalで足ります。
sealed classとsealed interfaceの使い分け
共通のフィールドや実装を親に持たせたいならsealed class、振る舞いの型だけを決めて実装は各型に任せるならsealed interfaceです。よく使われる組み合わせがsealed interfaceとrecordで、「取りうる値の種類を限定した型」を表せます。recordはクラスを継承できないので、recordを子にしたいなら親は必ずsealed interfaceになります。インターフェース全般の役割はインターフェースとは?意味と種類・コードと通信の契約を実装目線で解説を参照してください。
シール型へのキャストとコンパイル時の拒否
シールにすると、キャストの判定も厳しくなります。次のBoxはfinalではないので、通常のインターフェースOpenへのキャストは「BoxのサブクラスがOpenを実装するかもしれない」としてコンパイルが通ります。一方Shapeの実装はCircleしかありえないので、(Shape) bは不適合な型: BoxをShapeに変換できませんで止まりました。
sealed interface Shape permits Circle { }
final class Circle implements Shape { }
interface Open { }
class Box { }
class Casts {
static Shape f(Box b) { return (Shape) b; } // コンパイルエラー
static Open g(Box b) { return (Open) b; } // 通る
}
JEP 409はこれを縮小参照変換(narrowing reference conversion)の定義変更として説明しています。実行するまで分からなかったClassCastExceptionの一部が、コンパイル時に見つかるということです。
switchのパターンマッチと組み合わせた網羅チェック(Java 21以降)
Java 21のswitchは、対象がシールされた型ならpermitsの一覧を見て網羅性を判定します。対象が抽象シールクラスまたはシールインターフェースなら、すべての許可サブタイプをガードなしの型パターンで扱うことで網羅できます。具象シールクラスでは親自身のインスタンスも対象になるため、親型のcaseやdefaultが必要です(sealed class A permits Bでcase Bだけを書くと、JDK 25でも網羅性エラーになりました)。網羅的なswitchでもnullは別扱いで、case nullが無ければNullPointerExceptionになります。
sealed interface Shape permits Circle, Square { }
record Circle(double r) implements Shape { }
record Square(double s) implements Shape { }
class Geometry {
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.r() * c.r();
case Square s -> s.s() * s.s();
};
}
}
許可サブタイプ追加時のcase漏れとコンパイルエラー
あとからTriangleをpermitsに足すと、このareaはswitch式がすべての可能な入力値をカバーしていませんでコンパイルが止まります。Shapeで分岐している箇所がコードベースに何十か所あっても、直すべき場所をコンパイラが全部挙げてくれます。ifとinstanceofの連鎖では、この検査は働きません。
defaultによる型追加時のcase漏れの見逃し
逆に、念のためdefault -> 0.0;を書いておくと、Triangleを足してもコンパイルは通り、area(new Triangle(3, 4))は黙って0.0を返しました。シールした型のswitchにdefaultを書くと、追加漏れの検出を自分で捨てることになります。全部の型を列挙できるならdefaultは書きません。
non-sealedの枝全体を扱う型パターン
「許可サブクラスにnon-sealedが混じるとdefaultが必要になる」という説明を見かけますが、JDK 25で試すと誤りでした。Truckがnon-sealedで、その下にBigTruckがいても、case Truck tを1つ書けばBigTruckも含めて網羅になり、defaultなしで通ります。fee(new BigTruck())は300を返しました。
sealed interface Vehicle permits Car, Truck { }
final class Car implements Vehicle { }
non-sealed class Truck implements Vehicle { }
class BigTruck extends Truck { }
class Fees {
static int fee(Vehicle v) {
return switch (v) {
case Car c -> 100;
case Truck t -> 300; // BigTruckもここに入る
};
}
}
網羅にならないのは、case Truck tを書かずにcase BigTruck bのようにnon-sealedの下の型だけを並べたときです。その下は誰でも継承できるので、BigTruck以外の子がありうるからです。
レコードパターンのネストと_(Java 22)
Java 21のレコードパターン(JEP 440)を使うと、recordの中身まで分解して分岐できます。ネストした場合も網羅性は検査され、Add(Num a, Num b)とAdd(Add a, Expr b)だけでは「左がNumで右がAdd」の組み合わせが漏れるので、コンパイルエラーになりました。
sealed interface Expr permits Num, Add, Neg { }
record Num(int v) implements Expr { }
record Add(Expr l, Expr r) implements Expr { }
record Neg(Expr e) implements Expr { }
class Exprs {
static int eval(Expr e) {
return switch (e) {
case Num(int v) -> v;
case Add(Num(var a), Num(var b)) -> a + b;
case Add(var l, var r) -> eval(l) + eval(r);
case Neg(var x) -> -eval(x);
};
}
static boolean isLeaf(Expr e) {
return switch (e) {
case Num _ -> true;
case Add _, Neg _ -> false; // Java 22以降
};
}
}
使わない変数を_で捨てる書き方はJEP 456でJava 22の正式機能です。--release 21ではunnamed variables are not supported in -source 21で止まるので、Java 21でプレビュー機能を使わない場合は、case Num nのように名前を付けます。また、複数パターンをまとめた分岐は、case Add a -> false;とcase Neg n -> false;に分けます。eval(new Add(new Num(2), new Neg(new Num(5))))は-3を返しました。このsealed interfaceとrecordの組み合わせは、Visitorパターンとは?ダブルディスパッチとJava・C#の実装例で書いていた分岐を置き換える手段にもなります。
再コンパイルしないと実行時に落ちる:MatchExceptionとIncompatibleClassChangeError
網羅チェックはコンパイル時の保証です。型を足した側だけをビルドし直し、switchを書いた側のクラスファイルが古いまま動くと、保証は崩れます。Javaはそのとき黙って誤動作するのではなく、例外で止めます。
switch側が古いままだとMatchException
Shape・Circle・Squareとareaを持つMainを一度コンパイルし、そのあとShapeのpermitsにTriangleを足し、Main以外のクラスだけを再コンパイルしました。古いMain.classのままTriangleを渡すと、次のように落ちます。
Exception in thread "main" java.lang.MatchException
at Main.area(Main.java:3)
at Main.main(Main.java:10)
javap -cでMain.classを見ると、defaultを書いていないのにMatchExceptionを投げる命令が入っています。MatchExceptionのAPIドキュメントはこの状況を「シールされたインターフェースの許可サブタイプが、コンパイル時と実行時で異なる場合」と名指ししています。Mainも再コンパイルすれば、実行前にコンパイルエラーで気づけます。
未許可のサブクラスに対するJVMのロード時検査
親がシールされる前にコンパイルした子のクラスファイルは、javacの検査を通っていません。そこで実行時にも検査があります。Vehicleがまだ通常の抽象クラスだったときにBike extends Vehicleをコンパイルし、あとでVehicleだけをsealed ... permits Carに変えて動かすと、Bikeのロード時に止まりました。
Exception in thread "main" java.lang.IncompatibleClassChangeError: Failed listed permitted subclass check: class Bike is not a permitted subclass of Vehicle
どちらも原因は同じで、シールされた型とそれを使うコードを別々にビルドしていることです。同じリポジトリなら全体を一緒にビルドすれば起きません。危ないのは、シールされた型をライブラリとして公開し、利用者が別のタイミングでビルドする場合です。既存の分岐で扱えない許可サブタイプを追加すると、利用者の網羅的なswitchは再コンパイルでエラーになります。再コンパイルしないまま、その新しい型の値が未対応のswitchに渡されると、実行時にMatchExceptionになります。公開APIのシール型にサブタイプを足すのは、メジャーバージョンを上げるべき変更として扱ってください。
Reflection API・javap・Jacksonによる許可サブクラスの取得
isSealed()とgetPermittedSubclasses()の戻り値
JDK 17でjava.lang.ClassにisSealed()とgetPermittedSubclasses()が追加されました。前述のShape(Circle・Square・enum Unitを許可)で実行した結果です。
System.out.println(Shape.class.isSealed()); // true
System.out.println(Arrays.toString(Shape.class.getPermittedSubclasses()));
// [class Circle, class Square, class Unit]
System.out.println(Circle.class.isSealed()); // false(recordは暗黙のfinal)
System.out.println(String.class.getPermittedSubclasses()); // null
注意したいのは最後の行です。JEP 409の本文は「シールされていなければ空の配列を返す」と書いていますが、正式なClass.getPermittedSubclasses()のAPI仕様は「シールされていなければnull」で、JDK 25の実行結果もnullでした。なお、シール型で返される配列の要素順は仕様上未規定で、取得できなかった許可サブタイプは配列に含まれません。JEPを読んでlengthを直接呼ぶコードを書くとNullPointerExceptionになるので、先にisSealed()で分けます。
クラスファイル側では、許可サブクラスはPermittedSubclasses属性として記録されます。javap -v Shapeの出力にこの属性とCircle・Squareの名前が並ぶので、ビルド済みのjarの中身がどちらの版のShapeかを確かめるときに使えます。
Jackson 3によるシール型のサブタイプ自動検出
許可サブクラスの一覧を読み取って動くライブラリの例が、JSONライブラリのJacksonです。jackson-databindのリリースノートによると、3.0系でJava 17のシール型からサブタイプを自動検出する機能(#5025)が入りました。ソースを見ると、@JsonSubTypesが無い型についてisSealed()を確かめてからgetPermittedSubclasses()の結果をサブタイプとして登録しています。jackson-databind 3.2.4で、@JsonSubTypesを書かずに試した結果です。Jackson 3はパッケージがtools.jackson.databindに変わりましたが、アノテーションは2系と同じcom.fasterxml.jackson.annotationを使います。
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import tools.jackson.databind.json.JsonMapper;
@JsonTypeInfo(use = JsonTypeInfo.Id.SIMPLE_NAME)
sealed interface Payment permits Card, Bank { }
record Card(String last4) implements Payment { }
record Bank(String code) implements Payment { }
public class T {
public static void main(String[] args) throws Exception {
var mapper = JsonMapper.builder().build();
System.out.println(mapper.writeValueAsString(new Card("4242")));
// {"@type":"Card","last4":"4242"}
Payment p = mapper.readValue("{\"@type\":\"Bank\",\"code\":\"0001\"}", Payment.class);
System.out.println(p);
// Bank[code=0001]
}
}
型識別子の出し方を決める@JsonTypeInfoは従来どおり必要ですが、子を足すたびに@JsonSubTypesの一覧を直す作業は要らなくなります。同じコードをjackson-databind 2.22.4で動かすと、書き出しは通るものの読み込みでInvalidTypeIdException(known type ids = [])になりました。2系のままなら@JsonSubTypesを書きます。
シールクラスを使うべき場面・避けるべき場面
使うべきなのは「種類が有限で、全種類を漏れなく扱いたい」型です。決済手段、APIのエラー種別、式や命令の構文木、注文の状態などがこれにあたります。JDK自身もjava.lang.constant.ConstantDescをString・Integerなどに限定したシールインターフェースにしています。種類が増えたときに分岐の直し漏れをコンパイラに見つけさせたいなら、sealed interfaceとrecordで書き、switchにdefaultを書かない、までを1セットにします。
避けるべきなのは次の3つです。1つ目は、フレームワークやプラグインのように第三者が新しい型を足す前提の拡張点です。2つ目は、前章で書いたとおり、別ビルドの利用者がいる公開ライブラリで、種類がまだ増えそうな型です。3つ目は、設計の初期で種類が週単位で変わる段階です。この段階で閉じるとpermitsと置き場所の制約が変更のたびに手間になるので、種類が落ち着いてから閉じても遅くありません。「他人に継承させたくない」だけならfinalで足ります。
業務システムでこうしたドメイン型の設計から相談したい場合は、弊社のフルスクラッチ開発でJavaによる設計・実装を支援しています。オブジェクト指向の設計全体での位置づけはオブジェクト指向とは?三本柱の実装手順とrecord・sealedまでJavaコードで解説にまとめています。
よくある質問
sealed classはJavaのどのバージョンから使えますか?
正式機能としてはJava 17(JEP 409)からで、JDK 15と16ではプレビュー扱いでした。ただし、シール型を対象にしたswitchのパターンマッチと網羅チェックが正式機能になったのはJava 21なので、シールクラスの利点をすべて使うならJava 21以降が前提です。
permitsは必ず書く必要がありますか?
許可する子をすべて親と同じソースファイルに書く場合は省略できます。別ファイルに分けるときはpermitsで列挙し、子は同じモジュール(名前なしモジュールなら同じパッケージ)に置きます。
non-sealedとは何ですか?
許可サブクラスに付ける修飾子の1つで、その子から先を通常のクラスと同じく誰でも継承できる状態に戻します。switchでは、その子の型のcaseを1つ書けば、その枝の子孫も扱えます。他の許可サブタイプも網羅していれば、抽象シールクラスまたはシールインターフェースに対するdefaultは不要です。
C#のsealedとJavaのsealedは同じですか?
名前は同じでも意味が違います。C#のsealedはそのクラスからの継承を完全に禁止する修飾子で、Javaのfinalにあたります。Javaのsealedは継承を許したうえで相手をpermitsで限定します。
Kotlinのsealed classとの違いは何ですか?
Kotlinのsealed classにはpermitsが無く、直接の子は同じモジュールかつ同じパッケージに置いたものに限られます。シールクラス自体は常に抽象クラスで、子にfinal・non-sealedのような修飾子を付ける義務もありません。網羅チェックはswitchではなくwhen式で働きます。