- JAVA
- 技術 💻
Java recordの使い方|classとの違い・エラー例をJava 21/25で
Java の record(レコード)は、値を運ぶための「変更できないデータクラス」を1行で定義する仕組みです。record Point(int x, int y) {} と書くと、コンストラクタ・アクセサ x()・equals・hashCode・toString をコンパイラが自動で作ります。 Java 16 で正式機能になり、Java 21 では record を分解して値を取り出す「レコードパターン」も正式になりました。
record Point(int x, int y) {}
Point p = new Point(1, 2);
System.out.println(p); // Point[x=1, y=2]
System.out.println(p.x()); // 1(getX() ではない)
System.out.println(p.equals(new Point(1, 2))); // true(値で比べる)
この記事のコードは、すべて JDK 21 と JDK 25 でコンパイル・実行し、出力をそのまま載せています。Java 21 と 25 で結果が変わる箇所(無名変数 _、this(...) の前に処理を書く書き方、エラー文)は、両方の出力を並べました。
record と class の違い(早見表)
| 項目 | record | 普通の class |
|---|---|---|
| 書く量 | record User(String name, int age) {} の1行 |
フィールド・コンストラクタ・getter・equals・hashCode・toString を自分で書く |
| フィールド | 各コンポーネントが private final になる |
自由(final でなくてもよい) |
| 値を返すメソッド | name()(アクセサ) |
慣習で getName() |
| setter | 作れない(値は変えられない) | 作れる |
| equals / hashCode | 全コンポーネントの値で比べる(自動) | 書かなければ「同じオブジェクトか」で比べる |
| toString | User[name=sato, age=30](自動) |
User@1b6d3586 のような形 |
| 継承 | java.lang.Record を暗黙に継承し、extends は書けない。クラス自体が final |
extends できる・継承される |
| インターフェース | implements できる |
できる |
| 追加のインスタンスフィールド | 書けない(static は書ける) | 書ける |
| コンストラクタ | 正規・コンパクト・委譲用を書ける | 自由 |
| 分解(パターン) | case Point(int x, int y) で取り出せる(Java 21) |
できない |
| 主な用途 | DTO、値オブジェクト、複数の戻り値、Map のキー | 状態が変わるもの、継承するもの、JPA エンティティ |
判断の基準は「作ったあとで値を変える必要があるか」です。 変えないなら record、変えるなら class が向いています。
| 使いたい場所 | record か class か | 理由 |
|---|---|---|
| API のリクエスト・レスポンス(DTO) | record | 値を運ぶだけ。Jackson がそのまま読み書きできる |
| メソッドから複数の値を返す | record | メソッドの中にローカル record も書ける |
| Map のキー・Set の要素 | record | equals と hashCode が値でそろう |
| 金額・期間などの値オブジェクト | record | コンパクトコンストラクタで不正な値を防げる |
Spring Boot の設定値(@ConfigurationProperties) |
record | コンストラクタでバインドされる |
JPA の @Embeddable・複合主キー |
record(Jakarta Persistence 3.2 以降) | 仕様で record が認められた |
JPA の @Entity |
class | 仕様で record はエンティティにできない |
| 画面の入力途中の値など、変わる状態 | class | record は値を書き換えられない |
| 親クラスを継承したい | class | record は extends できない |
| 引数なしコンストラクタと setter が必要なライブラリ | class | record は引数ありのコンストラクタだけ |
確認環境:OpenJDK 21.0.10 と OpenJDK 25.0.4.1(Ubuntu 24.04)で javac・java を実行。コンパイルエラーは英語ロケール(LANG=C)の出力です。Jackson は jackson-databind 3.2.3(GitHub のソースからビルド)で実行しました。Spring Boot・Jakarta Persistence・Hibernate の扱いは、各公式ドキュメント・仕様書で確かめました(確認日 2026年10月16日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java recordの使い方|classとの違い・エラー例をJava 21/25で」
Java record の基本の使い方
定義と自動で作られるもの
record は「名前」と「持つ値の一覧(コンポーネント)」を書くだけで定義できます。コンパイラが何を作るかは、javap でクラスファイルを見ると分かります。
public record P(int x, int y) {}
$ javap -p P.class
Compiled from "P.java"
public final class P extends java.lang.Record {
private final int x;
private final int y;
public P(int, int);
public final java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public int x();
public int y();
}
クラスは final、親は java.lang.Record、フィールドは private final です。全項目を受け取るコンストラクタ(正規コンストラクタ)と、フィールドと同じ名前のアクセサ x()・y() が作られています。
equals・hashCode・toString の実際の出力
同じ内容を class で書いた場合と比べます。
import java.util.*;
public class Basic {
record Point(int x, int y) {}
static final class PointClass { // 同じことを class で書いた場合
private final int x, y;
PointClass(int x, int y) { this.x = x; this.y = y; }
int getX() { return x; }
int getY() { return y; }
}
public static void main(String[] args) {
Point p1 = new Point(1, 2);
Point p2 = new Point(1, 2);
System.out.println("toString : " + p1);
System.out.println("x() : " + p1.x());
System.out.println("p1 == p2 : " + (p1 == p2));
System.out.println("equals : " + p1.equals(p2));
System.out.println("hashCode : " + p1.hashCode() + " / " + p2.hashCode());
System.out.println("Setに含む : " + new HashSet<>(List.of(p1)).contains(p2));
PointClass c1 = new PointClass(1, 2), c2 = new PointClass(1, 2);
System.out.println("class equals: " + c1.equals(c2));
System.out.println("class toStr : " + c1.toString().replaceAll("@[0-9a-f]+", "@(ハッシュ値)"));
}
}
実行結果(JDK 21・25 とも同じ):
toString : Point[x=1, y=2]
x() : 1
p1 == p2 : false
equals : true
hashCode : 33 / 33
Setに含む : true
class equals: false
class toStr : Basic$PointClass@(ハッシュ値)
別々に作った record でも、値が同じなら equals は true、hashCode も一致します。 そのため HashSet や HashMap のキーにそのまま使えます。== は「同じオブジェクトか」を比べるので false です。class のほうは equals を書いていないため false になりました。equals と hashCode を自分で書くときの注意は、関連記事「Java equals・hashCodeのオーバーライド方法|不具合を再現」で扱っています。
null・double の比べ方(知っておくと迷わない細部)
自動の equals は、double を == ではなく Double.compare と同じ考え方で比べます。null を持つ場合も例外にはなりません。
public class EqDouble {
record D(double v) {}
public static void main(String[] args) {
System.out.println("NaN == NaN : " + (Double.NaN == Double.NaN));
System.out.println("new D(NaN).equals : " + new D(Double.NaN).equals(new D(Double.NaN)));
System.out.println("0.0 == -0.0 : " + (0.0 == -0.0));
System.out.println("new D(0.0).equals(-0): " + new D(0.0).equals(new D(-0.0)));
record Named(String name, Integer age) {}
System.out.println("null を含む toString : " + new Named(null, null));
System.out.println("null を含む equals : " + new Named(null, null).equals(new Named(null, null)));
System.out.println("hashCode : " + new Named(null, null).hashCode());
}
}
NaN == NaN : false
new D(NaN).equals : true
0.0 == -0.0 : true
new D(0.0).equals(-0): false
null を含む toString : Named[name=null, age=null]
null を含む equals : true
hashCode : 0
== と結果が逆になる組み合わせがあります。金額のように厳密な比較が要る値は、double ではなく BigDecimal や long(最小単位の整数)で持つほうが扱いやすくなります。main の中に record Named を書いたように、メソッドの中でもローカル record を定義できます。
メソッド・static メンバーの追加
| 書けるもの | 書けないもの |
|---|---|
| インスタンスメソッド・static メソッド | コンポーネント以外のインスタンスフィールド |
| static フィールド・static 初期化子 | インスタンス初期化子 { ... } |
| 正規コンストラクタ・コンパクトコンストラクタ | extends(親クラスの指定) |
委譲用のコンストラクタ(this(...) を呼ぶもの) |
public でないアクセサ |
入れ子の型、implements |
abstract・native メソッド |
書けないものを書いたときのエラー文は、後半の「よくあるコンパイルエラー」にまとめました。
コンパクトコンストラクタで入力を検証する
コンパクトコンストラクタは、引数リストを省略して書く record 専用のコンストラクタです。 検証や値の整え直しだけを書けば、フィールドへの代入はコンパイラが最後に行います。
import java.util.*;
public class Compact {
record Range(int min, int max) {
Range { // コンパクトコンストラクタ
if (min > max) {
throw new IllegalArgumentException("min(" + min + ") > max(" + max + ")");
}
}
int length() { return max - min; }
static Range of(int a, int b) { return new Range(Math.min(a, b), Math.max(a, b)); }
}
record Email(String value) {
Email {
Objects.requireNonNull(value, "value");
value = value.strip().toLowerCase(Locale.ROOT); // 引数を整えてから代入される
if (!value.contains("@")) throw new IllegalArgumentException("メールアドレスではありません: " + value);
}
}
record Money(long amount, String currency) {
Money(long amount) { this(amount, "JPY"); } // 正規コンストラクタに委譲する
}
public static void main(String[] args) {
System.out.println(Range.of(9, 3) + " length=" + Range.of(9, 3).length());
try { new Range(5, 1); } catch (IllegalArgumentException e) { System.out.println("例外: " + e.getMessage()); }
System.out.println(new Email(" Taro@Example.COM "));
try { new Email("taro.example.com"); } catch (IllegalArgumentException e) { System.out.println("例外: " + e.getMessage()); }
try { new Email(null); } catch (NullPointerException e) { System.out.println("例外: NullPointerException: " + e.getMessage()); }
System.out.println(new Money(1200));
}
}
Range[min=3, max=9] length=6
例外: min(5) > max(1)
Email[value=taro@example.com]
例外: メールアドレスではありません: taro.example.com
例外: NullPointerException: value
Money[amount=1200, currency=JPY]
ポイントは3つです。
- 検証に通らない値のオブジェクトは、そもそも作れません。 後から「不正な Range」が紛れ込む心配がなくなります。
- コンパクトコンストラクタの中では、引数(
value)に代入し直すと、その値がフィールドに入ります。this.value = ...と書くとコンパイルエラーです(後述)。 - 引数の数が違うコンストラクタ(
Money(long amount))は、最初にthis(...)で正規コンストラクタを呼ぶ必要があります。
Java 25 では this(...) の前に処理を書ける
Java 21 までは、委譲用コンストラクタの1文目が this(...) でなければなりませんでした。Java 25 で正式になった JEP 513(Flexible Constructor Bodies)により、this(...) の前に引数の加工を書けます。
public class F1 {
record Money(long amount, String currency) {
Money(String text) {
long v = Long.parseLong(text.replace(",", "")); // this(...) より前に処理を書く
this(v, "JPY");
}
}
public static void main(String[] args) {
System.out.println(new Money("12,800"));
}
}
| JDK | 結果 |
|---|---|
| 21.0.10 | error: constructor is not canonical, so its first statement must invoke another constructor of class Money と error: call to this must be first statement in constructor の2件 |
| 25.0.4.1 | コンパイルが通り、Money[amount=12800, currency=JPY] |
JEP 513 には、record の正規コンストラクタは今までどおり this(...) を呼べず、正規でないコンストラクタは this(...) を含む必要がある、と書かれています。変わったのは「this(...) の前に文を置けるようになった」点だけです。
Serializable な record は復元時にも検証が走る
record をシリアライズ(バイト列に変換)して復元すると、正規コンストラクタが呼ばれ、コンパクトコンストラクタの検証も動きます。 普通の class は、復元時にコンストラクタを通りません。バイト列の min を書き換えて試しました。
import java.io.*;
public class Ser {
record Range(int min, int max) implements Serializable {
Range {
if (min > max) throw new IllegalArgumentException("min(" + min + ") > max(" + max + ")");
}
}
static final class RangeClass implements Serializable {
final int min, max;
RangeClass(int min, int max) {
if (min > max) throw new IllegalArgumentException("min > max");
this.min = min; this.max = max;
}
}
static byte[] write(Object o) throws IOException {
var bos = new ByteArrayOutputStream();
try (var out = new ObjectOutputStream(bos)) { out.writeObject(o); }
return bos.toByteArray();
}
static Object read(byte[] b) throws Exception {
try (var in = new ObjectInputStream(new ByteArrayInputStream(b))) { return in.readObject(); }
}
// バイト列の中の int 値(min)を書き換えて「不正なデータ」を作る
static byte[] tamper(byte[] b, int from, int to) {
for (int i = b.length - 4; i >= 0; i--) {
if (b[i] == 0 && b[i+1] == 0 && b[i+2] == 0 && b[i+3] == from) { b[i+3] = (byte) to; return b; }
}
throw new IllegalStateException();
}
public static void main(String[] args) throws Exception {
System.out.println("record 復元: " + read(write(new Range(1, 5))));
try {
read(tamper(write(new Range(1, 5)), 1, 9));
} catch (Exception e) {
System.out.println("record 改ざん: " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
RangeClass c = (RangeClass) read(tamper(write(new RangeClass(1, 5)), 1, 9));
System.out.println("class 改ざん : min=" + c.min + ", max=" + c.max + "(コンストラクタを通らない)");
}
}
record 復元: Range[min=1, max=5]
record 改ざん: InvalidObjectException: min(9) > max(5)
class 改ざん : min=9, max=5(コンストラクタを通らない)
class では min > max の不正なオブジェクトがそのまま復元されました。record では InvalidObjectException で止まります。
不変性の落とし穴:List と配列
record のフィールドは final ですが、中のオブジェクトまで変更できなくなるわけではありません。 これを「浅い不変」と呼びます。
List は List.copyOf で防御的コピーをする
import java.util.*;
public class Copy {
record LooseTeam(String name, List<String> members) {}
record Team(String name, List<String> members) {
Team {
Objects.requireNonNull(name, "name");
members = List.copyOf(members); // 変更できないコピーに置き換える
}
}
record Wrapped(List<String> members) {
Wrapped {
members = Collections.unmodifiableList(members); // コピーではなく「読み取り専用の窓」
}
}
public static void main(String[] args) {
List<String> src = new ArrayList<>(List.of("sato"));
LooseTeam loose = new LooseTeam("A", src);
Team safe = new Team("B", src);
Wrapped wrapped = new Wrapped(src);
src.add("suzuki"); // 元のリストを後から変更
System.out.println("LooseTeam : " + loose.members());
System.out.println("Team : " + safe.members());
System.out.println("Wrapped : " + wrapped.members());
try { safe.members().add("tanaka"); }
catch (UnsupportedOperationException e) { System.out.println("Team.members().add → " + e.getClass().getSimpleName()); }
try { new Team("C", Arrays.asList("sato", null)); }
catch (NullPointerException e) { System.out.println("null要素 → NullPointerException"); }
}
}
LooseTeam : [sato, suzuki]
Team : [sato]
Wrapped : [sato, suzuki]
Team.members().add → UnsupportedOperationException
null要素 → NullPointerException
| 書き方 | 元の List を変えると | record から取り出して add すると | null 要素 |
|---|---|---|---|
| そのまま持つ | record の中身も変わる | 追加できてしまう | 入る |
Collections.unmodifiableList |
record の中身も変わる(窓なので) | UnsupportedOperationException |
入る |
List.copyOf |
変わらない | UnsupportedOperationException |
NullPointerException |
Collections.unmodifiableList は「読み取り専用の窓」で、元のリストの変更は見えてしまいます。record で守りたいなら List.copyOf(Set なら Set.copyOf、Map なら Map.copyOf)です。 UnsupportedOperationException が出たときの読み方は、関連記事「UnsupportedOperationExceptionの直し方|Java」で扱っています。不変クラス全般の作り方は、関連記事「Java不変クラスの作り方|finalと防御的コピー、recordまで」で扱っています。
配列は equals が中身で比べない
record Bytes(byte[] data) {}
record SafeBytes(byte[] data) {
SafeBytes { data = data.clone(); } // 受け取るときにコピー
public byte[] data() { return data.clone(); } // 渡すときもコピー
@Override public boolean equals(Object o) { return o instanceof SafeBytes(byte[] d) && Arrays.equals(data, d); }
@Override public int hashCode() { return Arrays.hashCode(data); }
@Override public String toString() { return "SafeBytes[data=" + Arrays.toString(data) + "]"; }
}
Bytes equals : false
Bytes toString : Bytes[data=[B@(ハッシュ値)]
SafeBytes equals : true
SafeBytes : SafeBytes[data=[1, 2]]
配列は自分自身の equals を中身で比べないため、record の自動 equals も「同じ配列か」で比べます。配列を持つなら、List に置き換えるか、上の SafeBytes のように4つ(コピー・equals・hashCode・toString)を書き直します。
レコードパターンと switch(Java 21)
レコードパターンは、case Card(String brand, int amount) のように record を分解して、中の値を変数に取り出す書き方です。 sealed(実装できる型を限定する仕組み)と組み合わせると、default なしで「すべての型を書いたか」をコンパイラが確かめます。
import java.util.*;
public class Patterns {
// 支払い方法を sealed で「この3種類だけ」と決める
sealed interface Payment permits Card, BankTransfer, Points {}
record Card(String brand, int amount) implements Payment {}
record BankTransfer(String bank, int amount) implements Payment {}
record Points(int points) implements Payment {}
static int fee(Payment p) {
return switch (p) {
case Card(String brand, int amount) when brand.equals("AMEX") -> amount * 35 / 1000;
case Card(var brand, var amount) -> amount * 30 / 1000; // var で型を省略
case BankTransfer(var bank, var amount) -> 440;
case Points(var points) -> 0;
}; // default は不要
}
// 入れ子のパターン
record Address(String pref, String city) {}
record Customer(String name, Address address) {}
record Order(Customer customer, Payment payment) {}
static String shippingNote(Object o) {
if (o instanceof Order(Customer(var name, Address(var pref, var city)), Card c)) {
return name + "様(" + pref + city + ")カード払い " + c.amount() + "円";
}
return "対象外";
}
// 型引数つき record のパターン
record Pair<A, B>(A first, B second) {}
static String describe(Pair<?, ?> pair) {
return switch (pair) {
case Pair(String s, Integer i) -> "文字列と整数: " + s + ", " + i;
case Pair(var a, var b) -> "その他: " + a + ", " + b;
};
}
static String nullSafe(Payment p) {
return switch (p) {
case null -> "支払い方法が未選択";
case Card c -> "カード";
case BankTransfer b -> "振込";
case Points pt -> "ポイント";
};
}
public static void main(String[] args) {
List<Payment> list = List.of(new Card("VISA", 10000), new Card("AMEX", 10000),
new BankTransfer("みずほ", 10000), new Points(500));
for (Payment p : list) System.out.printf("%-38s 手数料=%d円%n", p, fee(p));
System.out.println(shippingNote(new Order(new Customer("佐藤", new Address("東京都", "千代田区")), new Card("VISA", 3300))));
System.out.println(shippingNote(new Order(new Customer("鈴木", new Address("大阪府", "大阪市")), new Points(100))));
System.out.println(describe(new Pair<>("A", 1)));
System.out.println(describe(new Pair<>(1.5, List.of())));
System.out.println(nullSafe(null));
try { fee(null); } catch (NullPointerException e) { System.out.println("case null なし → NullPointerException"); }
}
}
実行結果(JDK 21・25 とも同じ):
Card[brand=VISA, amount=10000] 手数料=300円
Card[brand=AMEX, amount=10000] 手数料=350円
BankTransfer[bank=みずほ, amount=10000] 手数料=440円
Points[points=500] 手数料=0円
佐藤様(東京都千代田区)カード払い 3300円
対象外
文字列と整数: A, 1
その他: 1.5, []
支払い方法が未選択
case null なし → NullPointerException
| 書き方 | 意味 | 使える版 |
|---|---|---|
case Card(String brand, int amount) |
型を書いて分解する | Java 21 |
case Card(var brand, var amount) |
型を推論させる | Java 21 |
case Card(...) when 条件 |
条件(ガード)を付ける。上から順に評価 | Java 21 |
Order(Customer(var n, Address(var p, var c)), Card c) |
入れ子を一度に分解する | Java 21 |
case Pair(String s, Integer i) |
型引数つき record も分解できる | Java 21 |
case null -> |
null を受ける。書かないと NullPointerException |
Java 21 |
case Card(_, var amount) |
使わない値を _ で捨てる(無名変数) |
Java 22 以降(JEP 456) |
sealed で型が決まっているので、default を書かなくてもコンパイルが通ります。 逆に、Points の case を書き忘れると、Java 21 でも 25 でも次のエラーで止まります。
E8.java:8: error: the switch expression does not cover all possible input values
return switch (p) {
^
1 error
新しい支払い方法を permits に足したとき、対応していない switch をコンパイラがすべて指摘するのが、sealed+record の大きな利点です。sealed の書き方は、関連記事「Java sealedクラスの使い方|permitsとswitchの書き方」で扱っています。switch のパターンの一覧は、関連記事「Java switchパターンマッチング|record・sealedの書き方と実例」で扱っています。
無名変数 _(Java 21 と 25 の違い)
if (o instanceof Card(_, var amount)) return amount;
| JDK | 結果 |
|---|---|
| 21.0.10 | error: unnamed variables are a preview feature and are disabled by default. |
| 25.0.4.1 | コンパイルが通る(Java 22 で正式、JEP 456) |
Java 21 では、使わない値も var brand のように名前を付けて受けます。
Jackson で record を JSON にする(Jackson 3.2.3 で実行)
Jackson は 2.12 から record に対応しており、アノテーションなしでコンポーネント名が JSON のキーになります。 Spring Boot 4 の既定は Jackson 3(パッケージが tools.jackson)です。Jackson 3.2.3 で実行した結果を載せます。
import java.util.*;
import com.fasterxml.jackson.annotation.JsonProperty;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.DeserializationFeature;
public class JsonRecord {
record Item(String sku, int qty) {}
record Order(@JsonProperty("order_id") long id, List<Item> items) {
Order {
if (id <= 0) throw new IllegalArgumentException("id は正の数: " + id);
items = List.copyOf(items);
}
int totalQty() { return items.stream().mapToInt(Item::qty).sum(); } // アクセサではないのでJSONに出ない
}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
System.out.println("Jackson " + tools.jackson.databind.cfg.PackageVersion.VERSION);
Order o = new Order(1001, List.of(new Item("A-1", 2), new Item("B-7", 1)));
String json = mapper.writeValueAsString(o);
System.out.println("書き出し : " + json);
Order back = mapper.readValue(json, Order.class);
System.out.println("読み込み : " + back);
System.out.println("equals : " + o.equals(back));
Item partial = mapper.readValue("{\"sku\":\"C-3\"}", Item.class);
System.out.println("項目不足 : " + partial);
Item unknown = mapper.readValue("{\"sku\":\"C-3\",\"qty\":1,\"memo\":\"x\"}", Item.class);
System.out.println("未知の項目: " + unknown);
try {
mapper.readValue("{\"order_id\":0,\"items\":[]}", Order.class);
} catch (Exception e) {
System.out.println("検証エラー: " + e.getClass().getSimpleName());
System.out.println(" " + e.getMessage().lines().findFirst().orElse(""));
}
JsonMapper strict = JsonMapper.builder()
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
try {
strict.readValue("{\"sku\":\"C-3\",\"qty\":1,\"memo\":\"x\"}", Item.class);
} catch (Exception e) {
System.out.println("strict未知: " + e.getClass().getSimpleName());
System.out.println(" " + e.getMessage().lines().findFirst().orElse(""));
}
try {
Item r = strict.readValue("{\"sku\":\"C-3\"}", Item.class);
System.out.println("strict不足: 例外なし " + r);
} catch (Exception e) {
System.out.println("strict不足: " + e.getClass().getSimpleName());
}
JsonMapper req = JsonMapper.builder()
.enable(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES).build();
try {
req.readValue("{\"sku\":\"C-3\"}", Item.class);
} catch (Exception e) {
System.out.println("必須チェック: " + e.getClass().getSimpleName());
System.out.println(" " + e.getMessage().lines().findFirst().orElse(""));
}
}
}
実行結果(JDK 21・25 とも同じ):
Jackson 3.2.3
書き出し : {"order_id":1001,"items":[{"sku":"A-1","qty":2},{"sku":"B-7","qty":1}]}
読み込み : Order[id=1001, items=[Item[sku=A-1, qty=2], Item[sku=B-7, qty=1]]]
equals : true
項目不足 : Item[sku=C-3, qty=0]
未知の項目: Item[sku=C-3, qty=1]
検証エラー: ValueInstantiationException
Cannot construct instance of `JsonRecord$Order`, problem: id は正の数: 0
strict未知: UnrecognizedPropertyException
Unrecognized property "memo" (class JsonRecord$Item), not marked as ignorable (2 known properties: "sku", "qty")
strict不足: 例外なし Item[sku=C-3, qty=0]
必須チェック: MismatchedInputException
Missing creator property 'qty' (index 1); `DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES` enabled
結果から分かることを表にまとめます。
| 確かめたこと | Jackson 3.2.3 の動き |
|---|---|
| キー名 | コンポーネント名。@JsonProperty("order_id") で変えられる |
totalQty() のような追加メソッド |
JSON に出ない(アクセサだけが対象) |
| 読み込み時のコンストラクタ | 正規コンストラクタが呼ばれ、コンパクトコンストラクタの検証も動く |
| 検証エラー | ValueInstantiationException(メッセージに例外の文言が入る) |
| JSON に項目が無い | int は 0、参照型は null で作られる。FAIL_ON_NULL_FOR_PRIMITIVES を有効にしても例外にならなかった |
| 必須にしたい | FAIL_ON_MISSING_CREATOR_PROPERTIES を有効にすると MismatchedInputException |
| 知らない項目(memo) | 既定では無視された。FAIL_ON_UNKNOWN_PROPERTIES を有効にすると UnrecognizedPropertyException |
コンパクトコンストラクタに書いた検証は、JSON から作るときも働きます。 不正な JSON を受けたときに、検証を通らない record は作られません。ObjectMapper 全般の設定は、関連記事「Jackson ObjectMapperの使い方|JSON変換・日付・record」で扱っています。検証エラーの読み方は関連記事「Jackson Cannot construct instanceの直し方」で扱っています。未知の項目のエラーは関連記事「Jackson UnrecognizedPropertyExceptionの直し方」で扱っています。
Spring Boot で record を使う
Spring Boot での使いどころは、主に「API の DTO」と「設定値」です。この節は Spring Boot 4.1 の公式リファレンスに沿って書いています。
リクエスト・レスポンスの DTO
public record CreateUserRequest(
@NotBlank String name,
@Email String email) {}
public record UserResponse(long id, String name) {}
@PostMapping("/users")
public UserResponse create(@Valid @RequestBody CreateUserRequest req) {
long id = userService.create(req.name(), req.email());
return new UserResponse(id, req.name());
}
Bean Validation のアノテーション(@NotBlank など)は、コンポーネントに付けられます。入力チェックの書き方は、関連記事「Spring Bootバリデーション一覧|@Validと@Validated」で扱っています。
@ConfigurationProperties で設定値を受け取る
@ConfigurationProperties("app.mail")
public record MailProperties(
String from,
@DefaultValue("3") int retry,
Smtp smtp) {
public record Smtp(String host, @DefaultValue("587") int port) {}
}
app.mail.from=noreply@example.com
app.mail.smtp.host=smtp.example.com
@SpringBootApplication
@ConfigurationPropertiesScan // または @EnableConfigurationProperties(MailProperties.class)
public class App { }
Spring Boot のリファレンスには次のように書かれています。
- コンストラクタバインドは record で使える。コンストラクタが複数なければ
@ConstructorBindingは不要。 @DefaultValueはコンストラクタ引数と record のコンポーネントに付けられる(上の例ではretryが 3、portが 587 になる)。- 入れ子の型(上の
Smtp)もコンストラクタでバインドされる。 - 有効にするには
@EnableConfigurationPropertiesか、設定プロパティのスキャンを使う。@Componentや@Beanで作った Bean ではコンストラクタバインドは使えない。 -parameters付きでコンパイルする必要がある(Spring Boot の Gradle プラグインか、Maven のspring-boot-starter-parentなら自動で付く)。
プロパティの一覧は関連記事「Spring Boot application.properties設定一覧」で扱っています。
JPA(Jakarta Persistence)で record を使えるか
record は JPA のエンティティ(@Entity)にはできません。 Jakarta Persistence 3.2 の仕様書には、「enum、record、interface はエンティティに指定できない」と明記されています。エンティティには「引数なしの public か protected のコンストラクタ」「クラスが final でないこと」「永続化するフィールドやメソッドが final でないこと」が求められ、record はどれも満たしません。
一方、エンティティ以外の場所では record を使えます。
| 用途 | record | 根拠 |
|---|---|---|
エンティティ(@Entity) |
使えない | 仕様 2.1「enum, record, or interface may not be designated as an entity」 |
埋め込み型(@Embeddable) |
使える | 仕様 2.7「an embeddable class may be any Java record type」(3.2)。Hibernate は 6.2 から対応 |
複合主キー(@IdClass) |
使える | 仕様 2.4.1。record なら引数なしコンストラクタは不要(3.2) |
| 検索結果の受け取り(DTO 射影) | 使える | JPQL の select new、Spring Data JPA の射影 |
// 埋め込み型を record で書く(Jakarta Persistence 3.2 以降)
@Embeddable
public record Address(String pref, String city, String street) {}
@Entity
public class Customer { // エンティティは class のまま
@Id @GeneratedValue Long id;
String name;
@Embedded Address address;
protected Customer() {} // 引数なしコンストラクタ(仕様の要件)
}
// 検索結果だけを record で受け取る
public record CustomerSummary(Long id, String name) {}
@Query("select new com.example.CustomerSummary(c.id, c.name) from Customer c")
List<CustomerSummary> findSummaries();
エンティティは class、画面や API に渡す形や値の組は record、と役割で分けると、それぞれの良さが活きます。@Query やクエリメソッドの書き方は、関連記事「Spring Data JPA使い方|クエリメソッド命名一覧と@Query実例」で扱っています。
よくあるコンパイルエラーと直し方(JDK 21 / 25 の出力)
すべて実際に javac で出たエラー文です。JDK 21 と 25 で文言が同じものは1つだけ載せています。
| No. | やったこと | エラー文(要点) | 直し方 |
|---|---|---|---|
| E1 | インスタンスフィールドを追加 | field declaration must be static |
コンポーネントに足す |
| E2 | メソッドで値を書き換え | cannot assign a value to final variable name |
新しい record を返す(withXxx) |
| E3 | extends を書いた |
'{' expected |
共通部分は interface にする |
| E4 | record を継承した | cannot inherit from final User |
継承ではなく持たせる(委譲) |
| E5 | コンパクトで this.name = |
cannot assign a value to final variable name |
引数 name = ... に代入する |
| E6 | 委譲用コンストラクタで直接代入 | constructor is not canonical, so ... |
1文目(25 は先頭以外も可)で this(...) を呼ぶ |
| E7 | アクセサを public にしなかった | invalid accessor method in record User |
public を付ける |
| E8 | switch で case を書き漏らした | does not cover all possible input values |
漏れた型の case を書く |
| E9 | インスタンス初期化子を書いた | instance initializers not allowed in records |
コンストラクタに書く |
| E10 | パターンの項目数が違う | incorrect number of nested patterns |
全コンポーネントを並べる |
| E11 | Java 21 で _ を使った |
unnamed variables are a preview feature |
var x で受けるか Java 22 以降 |
E1:インスタンスフィールドを追加した
record User(String name) {
private int age;
}
E1.java:2: error: field declaration must be static
private int age;
^
(consider replacing field with record component)
1 error
record の状態はコンポーネントだけで表します。record User(String name, int age) のようにコンポーネントへ足します。
E2・E5:値を書き換えようとした
record User(String name) {
void rename(String n) { this.name = n; }
}
E2.java:2: error: cannot assign a value to final variable name
void rename(String n) { this.name = n; }
^
1 error
値を変えたいときは、新しい値で別のインスタンスを作ります。
record User(String name, int age) {
User withAge(int newAge) { return new User(name, newAge); }
}
コンパクトコンストラクタの中で this.name = name.strip(); と書いても同じエラー(E5)になります。コンパクトコンストラクタでは name = name.strip(); と引数に代入します。
E3・E4:継承しようとした
class Base {}
record User(String name) extends Base {}
E3.java:2: error: '{' expected
record User(String name) extends Base {}
^
1 error
record User(String name) {}
class Admin extends User {
Admin() { super("admin"); }
}
E4.java:2: error: cannot inherit from final User
class Admin extends User {
^
1 error
E3 のエラー文は継承のことに触れず「{ が要る」と出るため、原因に気づきにくい例です。共通の振る舞いは interface に定義して implements します。
E6:委譲用コンストラクタでフィールドに直接代入した
record Money(long amount, String currency) {
Money(long amount) {
this.amount = amount;
this.currency = "JPY";
}
}
JDK 21.0.10:
E6.java:2: error: constructor is not canonical, so its first statement must invoke another constructor of class Money
Money(long amount) {
^
1 error
JDK 25.0.4.1:
E6.java:2: error: constructor is not canonical, so it must invoke another constructor of class Money
Money(long amount) {
^
1 error
Java 25 では「1文目で」という条件が外れたため、文言も変わりました。どちらも this(amount, "JPY"); を呼ぶ形に直します。
E7:アクセサを public にしなかった
record User(String name) {
String name() { return name; }
}
E7.java:2: error: invalid accessor method in record User
String name() { return name; }
^
(accessor method must be public)
1 error
アクセサを自分で書き直すときは public String name() とします。
E9・E10:初期化子とパターンの項目数
record User(String name) {
static int count;
{ count++; }
}
E9.java:3: error: instance initializers not allowed in records
{ count++; }
^
1 error
record Card(String brand, int amount) {}
// ...
if (o instanceof Card(String brand)) return 0;
E10.java:4: error: incorrect number of nested patterns
if (o instanceof Card(String brand)) return 0;
^
required: String,int
found: String
1 error
レコードパターンでは、使わない値も含めて全コンポーネントを順に並べます。Java 22 以降は Card(String brand, _) と書けます。
リフレクションで見た record
フレームワークが record をどう見分けているかも確かめておきます。
System.out.println("isRecord : " + Point.class.isRecord());
System.out.println("superclass : " + Point.class.getSuperclass().getName());
System.out.println("final? : " + Modifier.isFinal(Point.class.getModifiers()));
for (var rc : Point.class.getRecordComponents()) {
System.out.println("component : " + rc.getName() + " : " + rc.getType());
}
isRecord : true
superclass : java.lang.Record
final? : true
component : x : int
component : y : int
isRecord() と getRecordComponents() で、ライブラリはコンポーネントの名前・型・順番を取得できます。 Jackson や Spring がアノテーションなしで record を扱えるのは、この情報を使っているためです。
よくある質問
Q. java record とは何ですか?
A. 値を運ぶための変更できないデータクラスを、record 名前(型 名前, ...) {} の1行で定義する Java の言語機能です。Java 16 で正式になりました(JEP 395)。コンストラクタ・アクセサ・equals・hashCode・toString をコンパイラが作ります。
Q. java record の使い方を一言でいうと? A. 「作ったあとで値を変えないデータ」を record にします。DTO、値オブジェクト、メソッドの複数の戻り値、Map のキーが代表的です。入力チェックはコンパクトコンストラクタに書きます。
Q. java record型と class の違いは? A. record はクラスもフィールドも final で、継承できず、setter もありません。その代わり equals・hashCode・toString が値で自動生成され、レコードパターンで分解できます。詳しくは冒頭の早見表を見ると比べられます。
Q. record に getter(getName)はありますか?
A. ありません。アクセサは name() の形です。getName() を前提にしたライブラリやテンプレートでは、record 対応の版か設定を確かめます。
Q. record と Lombok の @Value はどう違いますか?
A. どちらも変更できないデータクラスを作れます。record は言語機能なので追加の依存が要らず、レコードパターンで分解できます。@Value はアクセサが getName() の形で、@Builder などと組み合わせられます。Lombok の各アノテーションは関連記事「Lombokアノテーション一覧|展開後コードと使い分け早見表」で扱っています。
Q. record に setter や builder を付けたいときは?
A. setter は付けられません。値を変えた別のインスタンスを返す withXxx メソッドを書きます。項目が多くて builder が欲しい場合は、static の builder クラスを入れ子で書くか、Lombok の @Builder を record に付ける方法があります。
Q. Java 17 でも使えますか?
A. record 自体は Java 16 から使えます。switch のレコードパターンは Java 21 から、無名変数 _ は Java 22 から、this(...) の前に処理を書く書き方は Java 25 からです。
Q. Kotlin の data class や C# の record との違いは?
A. Kotlin の data class は copy() で一部の値を変えたコピーを作れ、var のプロパティも持てます。Java の record に copy に当たるものは無く、withXxx を自分で書きます。Kotlin は関連記事「Kotlin data classの使い方|copy・equalsとclass差」で扱っています。C# は関連記事「C# recordの使い方 class・structとの違いとwith式・等価性」で扱っています。
まとめ
- record は変更できないデータクラス。1行でコンストラクタ・アクセサ
x()・equals・hashCode・toString がそろう - 迷ったら 「作ったあとで値を変えるか」 で決める。変えないなら record
- コンパクトコンストラクタの検証は、
newのときだけでなく Jackson の読み込みとシリアライズの復元でも動く - List は
List.copyOfでコピー。unmodifiableListは元の変更が見える。配列は equals が中身で比べない - sealed+レコードパターン+switch で、書き漏れをコンパイラが見つける(Java 21)。
_は Java 22、this(...)の前の処理は Java 25 から - JPA のエンティティは class。
@Embeddable・@IdClass(Jakarta Persistence 3.2)と DTO 射影は record で書ける - Spring Boot の
@ConfigurationPropertiesは、コンストラクタが1つなら@ConstructorBindingなしでバインドされる
参考・出典
確認日はいずれも 2026年10月16日です。
- JEP 395: Records:https://openjdk.org/jeps/395
- JEP 440: Record Patterns:https://openjdk.org/jeps/440
- JEP 441: Pattern Matching for switch:https://openjdk.org/jeps/441
- JEP 456: Unnamed Variables & Patterns:https://openjdk.org/jeps/456
- JEP 513: Flexible Constructor Bodies:https://openjdk.org/jeps/513
- Java SE 21 API java.lang.Record:https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Record.html
- Java Object Serialization Specification 1.13「Serialization of Records」:https://docs.oracle.com/en/java/javase/21/docs/specs/serialization/serial-arch.html
- Jackson databind(GitHub、3.2.3):https://github.com/FasterXML/jackson-databind
- Jackson Release 2.12(Java 14 Record type support):https://github.com/FasterXML/jackson/wiki/Jackson-Release-2.12
- Spring Boot Reference「Externalized Configuration」(Constructor Binding):https://docs.spring.io/spring-boot/reference/features/external-config.html
- Jakarta Persistence 3.2 Specification:https://jakarta.ee/specifications/persistence/3.2/jakarta-persistence-spec-3.2
- Hibernate ORM 6.2 Final(records as embeddables):https://in.relation.to/2023/03/30/orm-62-final/
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java recordの使い方|classとの違い・エラー例をJava 21/25で」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。