C#のrecordとは、「中身の値が同じなら等しい」とみなされるデータ用の型を、1行で定義できるしくみです。 record Point(int X, int Y); と書くだけで、プロパティ、コンストラクター、値で比べる == と Equals、読みやすい ToString、一部だけ変えたコピーを作る with 式への対応がまとめて用意されます。

この記事では、class・struct・record・record struct・readonly record structの5つを「参照型か値型か・等価性・不変性・with式・継承」で比べる表と、どれを選ぶかのフローを最初に示します。そのうえで、== の結果がclassとrecordで変わる例、List<T> や配列をメンバーに持つと等価にならない例、with式が浅いコピーである例を、.NET 8(C# 12)で実際に動かした出力つきで確かめます。機能ごとのC#バージョン表(9〜14)と、JSONに変換するときの注意も載せています。

class・struct・recordの早見表

型 種類 等価性(== と Equals) 不変性 with式 継承 使えるバージョン
class 参照型 参照が同じかどうか(既定) 自分で設計する 使えない できる 最初から
struct 値型 Equals は値で比較。== は自分で定義 既定は可変(readonly struct で不変) 使える(C# 10以降) できない 最初から
record(record class) 参照型 値で比較(== も用意される) 位置指定プロパティは init で書き換え不可 使える record同士でできる C# 9以降
record struct 値型 値で比較(== も用意される) 位置指定プロパティは書き換え可能 使える できない C# 10以降
readonly record struct 値型 値で比較(== も用意される) 位置指定プロパティは init で書き換え不可 使える できない C# 10以降

同じ値を持つ2つのインスタンスを比べた結果(.NET 8.0.31で実行)は次のとおりです。

型 a == b a.Equals(b) ToString() の出力
class False False PointClass
record True True PointRecord { X = 1, Y = 2 }
struct コンパイルエラー(CS0019) True PointStruct
record struct True True PointRecordStruct { X = 1, Y = 2 }

「データを運ぶための型で、値が同じなら同じものとして扱いたい」ならrecord、「状態を持ち、振る舞いが中心」ならclassというのが基本の使い分けです。

検証に使った環境

項目 内容
.NET SDK 8.0.131(ランタイム 8.0.31)
C#の言語バージョン 12(.NET 8の既定)
実行方法 dotnet new console で作ったプロジェクトを dotnet run
JSON 標準ライブラリの System.Text.Json
未実行の範囲 .NET 9/C# 13、.NET 10/C# 14の機能、Newtonsoft.Json

記事中の実行結果は、1つの Program.cs として実行したものを章ごとに分けて掲載しています。型の定義は次の「recordの基本の書き方」にまとめてあります。

recordの基本の書き方

位置指定(1行で書く形)

record PointRecord(int X, int Y);

丸かっこの中に並べたものを「位置指定パラメーター」と呼び、同じ名前のプロパティとコンストラクターが作られます。この記事の検証で使った型は次のとおりです。

// 比較用の4つの型
class PointClass(int x, int y)
{
    public int X { get; } = x;
    public int Y { get; } = y;
}
record PointRecord(int X, int Y);
struct PointStruct(int x, int y)
{
    public int X { get; set; } = x;
    public int Y { get; set; } = y;
}
record struct PointRecordStruct(int X, int Y);
readonly record struct PointReadonlyRecordStruct(int X, int Y);

(classとstructの丸かっこは、C# 12で入ったプライマリコンストラクターです。recordの位置指定と違い、プロパティは自動では作られません。)

1行のrecordに用意されるメンバー

record Point(int X, int Y); に何が作られるかを、リフレクションで一覧にしました。

foreach (var m in typeof(Point)
    .GetMembers(System.Reflection.BindingFlags.Public
              | System.Reflection.BindingFlags.Instance
              | System.Reflection.BindingFlags.DeclaredOnly)
    .Select(m => m.ToString()).OrderBy(s => s))
    Console.WriteLine(m);

record Point(int X, int Y);

実行結果:

Boolean Equals(Point)
Boolean Equals(System.Object)
Int32 get_X()
Int32 get_Y()
Int32 GetHashCode()
Int32 X
Int32 Y
Point <Clone>$()
System.String ToString()
Void .ctor(Int32, Int32)
Void Deconstruct(Int32 ByRef, Int32 ByRef)
Void set_X(Int32)
Void set_Y(Int32)
用意されるもの 役割
プロパティ X、Y get と init(一覧では set_X と表示されるが、初期化時だけ代入できる)
コンストラクター new Point(1, 2) で作れる
Equals、GetHashCode すべてのプロパティの値で比較する
ToString Point { X = 1, Y = 2 } の形で出力する
Deconstruct var (x, y) = point; と分解できる
<Clone>$ with式が内部で使うコピー用のメソッド

静的な == と != の演算子も用意されます(上の一覧はインスタンスメンバーだけを出しています)。

プロパティを自分で書く形

位置指定を使わず、ふつうのプロパティとして書くこともできます。required(C# 11以降)を付けると、初期化時の指定が必須になります。

var u = new User { Name = "sato" };
var u2 = u with { Nick = "sa" };
Console.WriteLine(u2);

record User
{
    public required string Name { get; init; }
    public string? Nick { get; init; }
}

実行結果:

User { Name = sato, Nick = sa }
書き方 向いている場面
位置指定 record P(int X, int Y); 項目が少なく、すべて必須のとき。分解も使いたいとき
プロパティを書く形 項目が多いとき、任意の項目があるとき、名前を付けて初期化したいとき

分解(Deconstruct)

var r1 = new PointRecord(1, 2);
var (x, y) = r1;
Console.WriteLine($"x={x}, y={y}");

実行結果:

x=1, y=2

==の結果がclassとrecordで変わる例

同じ値で2つずつインスタンスを作り、==、Equals、ReferenceEquals を比べます。

var c1 = new PointClass(1, 2);        var c2 = new PointClass(1, 2);
var r1 = new PointRecord(1, 2);       var r2 = new PointRecord(1, 2);
var s1 = new PointStruct(1, 2);       var s2 = new PointStruct(1, 2);
var rs1 = new PointRecordStruct(1, 2); var rs2 = new PointRecordStruct(1, 2);

Console.WriteLine($"class         ==: {c1 == c2,-5} Equals: {c1.Equals(c2),-5} ReferenceEquals: {ReferenceEquals(c1, c2)}");
Console.WriteLine($"record        ==: {r1 == r2,-5} Equals: {r1.Equals(r2),-5} ReferenceEquals: {ReferenceEquals(r1, r2)}");
Console.WriteLine($"struct        ==: (定義なし) Equals: {s1.Equals(s2)}");
Console.WriteLine($"record struct ==: {rs1 == rs2,-5} Equals: {rs1.Equals(rs2)}");

実行結果:

class         ==: False Equals: False ReferenceEquals: False
record        ==: True  Equals: True  ReferenceEquals: False
struct        ==: (定義なし) Equals: True
record struct ==: True  Equals: True
  • classは「同じインスタンスかどうか」 を比べるので、値が同じでもFalseです。
  • recordは「型が同じで、すべてのプロパティの値が等しいか」 を比べるので、別のインスタンス(ReferenceEquals はFalse)でもTrueになります。
  • 通常のstructは Equals で値を比べられますが、== は自分で定義しない限り使えません。record structなら == も用意されます。

HashSetやDictionaryのキーにしたとき

等価性の違いは、コレクションの動きにそのまま表れます。

var setC = new HashSet<PointClass> { c1, c2 };
var setR = new HashSet<PointRecord> { r1, r2 };
Console.WriteLine($"HashSet<class>  の件数: {setC.Count}");
Console.WriteLine($"HashSet<record> の件数: {setR.Count}");

var stock = new Dictionary<PointRecord, string> { [new PointRecord(1, 2)] = "A棚" };
Console.WriteLine($"Dictionary を別インスタンスのキーで検索: {stock[new PointRecord(1, 2)]}");

実行結果:

HashSet<class>  の件数: 2
HashSet<record> の件数: 1
Dictionary を別インスタンスのキーで検索: A棚

recordは GetHashCode も値から計算されるので、重複の除去や、複数の値を組み合わせたキー(複合キー)にそのまま使えます。

ToStringの出力

Console.WriteLine(c1);
Console.WriteLine(r1);
Console.WriteLine(s1);
Console.WriteLine(rs1);

実行結果:

PointClass
PointRecord { X = 1, Y = 2 }
PointStruct
PointRecordStruct { X = 1, Y = 2 }

ログやデバッグ出力で中身がそのまま読めるのも、recordの便利な点です。

Listや配列をメンバーに持つと等価にならない例

recordの等価性は、各メンバーをその型の既定の方法で比べるしくみです。int や string は値で比べられますが、List<T> や配列は参照型で、既定の比較は「同じインスタンスかどうか」です。そのため、中身が同じでも別々に作ったリストを持つrecord同士は等しくなりません。

var o1 = new Order(1, new List<string> { "pen", "note" });
var o2 = new Order(1, new List<string> { "pen", "note" });
Console.WriteLine($"Order(List)    o1 == o2: {o1 == o2}");

var a1 = new Tags(new[] { "a", "b" });
var a2 = new Tags(new[] { "a", "b" });
Console.WriteLine($"Tags(配列)     a1 == a2: {a1 == a2}");

var shared = new List<string> { "pen", "note" };
Console.WriteLine($"同じ List を共有   : {new Order(1, shared) == new Order(1, shared)}");
Console.WriteLine($"ToString: {o1}");

record Order(int Id, List<string> Items);
record Tags(string[] Values);

実行結果:

Order(List)    o1 == o2: False
Tags(配列)     a1 == a2: False
同じ List を共有   : True
ToString: Order { Id = 1, Items = System.Collections.Generic.List`1[System.String] }

同じリストのインスタンスを共有しているときだけTrueになっています。ToString もリストの中身ではなく型名を出力します。

ImmutableArray<T> をメンバーにした場合も確かめましたが、結果は同じくFalseでした。

using System.Collections.Immutable;

var i1 = new Bag(ImmutableArray.Create("a", "b"));
var i2 = new Bag(ImmutableArray.Create("a", "b"));
Console.WriteLine($"ImmutableArray メンバー: {i1 == i2}");

record Bag(ImmutableArray<string> Items);
ImmutableArray メンバー: False

直し方:EqualsとGetHashCodeを自分で書く

コレクションの中身まで比べたいときは、recordの中で Equals と GetHashCode を書きます。書いたものが、自動で用意されるものの代わりに使われます。

var f1 = new OrderFixed(1, new List<string> { "pen", "note" });
var f2 = new OrderFixed(1, new List<string> { "pen", "note" });
Console.WriteLine($"OrderFixed     f1 == f2: {f1 == f2}");
Console.WriteLine($"ハッシュも一致     : {f1.GetHashCode() == f2.GetHashCode()}");

record OrderFixed(int Id, List<string> Items)
{
    public virtual bool Equals(OrderFixed? other) =>
        other is not null && Id == other.Id && Items.SequenceEqual(other.Items);

    public override int GetHashCode()
    {
        var hash = new HashCode();
        hash.Add(Id);
        foreach (var item in Items) hash.Add(item);
        return hash.ToHashCode();
    }
}

実行結果:

OrderFixed     f1 == f2: True
ハッシュも一致     : True
ポイント 内容
Equals の引数は自分の型 Equals(OrderFixed? other) を書くと、== もこれを使う
GetHashCode も一緒に書く 等しいもの同士は同じハッシュになる必要がある
順番を区別しない比較にしたいとき 並べ替えてから比べる、またはセットとして比べる
継承するrecordでは 基底との型の違いも考慮が必要(sealed record にしておくと単純になる)

with式の使い方と「浅いコピー」の注意

with式は、元のインスタンスを変えずに、一部のプロパティだけを変えた新しいインスタンスを作る書き方です。

var r1 = new PointRecord(1, 2);
var rw = r1 with { X = 10 };
Console.WriteLine($"record: {rw} / 元: {r1}");

var same = r1 with { };
Console.WriteLine($"with {{ }} は別インスタンス: {!ReferenceEquals(r1, same)}, 値は等しい: {r1 == same}");

実行結果:

record: PointRecord { X = 10, Y = 2 } / 元: PointRecord { X = 1, Y = 2 }
with { } は別インスタンス: True, 値は等しい: True

with式が浅いコピーである例

with式は各メンバーの値をそのまま写します。メンバーが参照型(リストなど)の場合、写されるのは参照なので、元とコピーが同じリストを指します。

var original = new Order(1, new List<string> { "pen" });
var copy = original with { Id = 2 };
copy.Items.Add("note");

Console.WriteLine($"original.Id = {original.Id}, copy.Id = {copy.Id}");
Console.WriteLine($"original.Items = [{string.Join(", ", original.Items)}]");
Console.WriteLine($"copy.Items     = [{string.Join(", ", copy.Items)}]");
Console.WriteLine($"同じ List か   : {ReferenceEquals(original.Items, copy.Items)}");

実行結果:

original.Id = 1, copy.Id = 2
original.Items = [pen, note]
copy.Items     = [pen, note]
同じ List か   : True

コピー側に追加した「note」が、元の original.Items にも入っています。Id は別々になっているのに、リストは共有されたままです。

直し方:コレクションは新しく作って渡す

var deep = original with { Id = 3, Items = new List<string>(original.Items) };
deep.Items.Add("eraser");
Console.WriteLine($"コピーしてから渡す: original = [{string.Join(", ", original.Items)}] / deep = [{string.Join(", ", deep.Items)}]");

実行結果:

コピーしてから渡す: original = [pen, note] / deep = [pen, note, eraser]
方法 内容
with式の中で新しいコレクションを渡す Items = new List<string>(original.Items)
メンバーを読み取り専用の型にする IReadOnlyList<string> や ImmutableList<string> にして、あとから追加できない形にする
入れ子のrecordは入れ子でwith order with { Address = order.Address with { City = "..." } }

recordの「不変」は、プロパティへの再代入ができないという意味で、メンバーの中身までは守りません。 コレクションを持たせるときは、型選びとコピーの方針を決めておくと安心です。

with式が使える型

var sw = s1 with { X = 10 };                 // struct
var rsw = rs1 with { X = 10 };               // record struct
var anon = new { Name = "sato", Age = 20 };  // 匿名型
var anon2 = anon with { Age = 21 };
Console.WriteLine($"struct: {sw.X},{sw.Y} / 元: {s1.X},{s1.Y}");
Console.WriteLine($"record struct: {rsw} / 元: {rs1}");
Console.WriteLine($"匿名型: {anon2}");

実行結果:

struct: 10,2 / 元: 1,2
record struct: PointRecordStruct { X = 10, Y = 2 } / 元: PointRecordStruct { X = 1, Y = 2 }
匿名型: { Name = sato, Age = 21 }

record、struct、record struct、匿名型で使えます。classには使えず、コンパイルエラー(CS8858)になります(次の章にエラー文を載せています)。

with式でも入力チェックは働く

init アクセサーに検証を書いておくと、コンストラクターでもwith式でも同じチェックが通ります。

try { var t = new Temp(-300); }
catch (ArgumentOutOfRangeException e) { Console.WriteLine(e.Message.Split('\n')[0]); }

try { var t = new Temp(20) with { Celsius = -300 }; }
catch (ArgumentOutOfRangeException e) { Console.WriteLine("with でも検証: " + e.Message.Split('\n')[0]); }

record Temp
{
    private readonly double _c;
    public Temp(double celsius) => Celsius = celsius;
    public double Celsius
    {
        get => _c;
        init => _c = value >= -273.15
            ? value
            : throw new ArgumentOutOfRangeException(nameof(value), "絶対零度より低い値は指定できません");
    }
}

実行結果:

絶対零度より低い値は指定できません (Parameter 'value')
with でも検証: 絶対零度より低い値は指定できません (Parameter 'value')

initと不変性:どこまで書き換えられないか

型ごとの違い

var m = new PointRecordStruct(1, 2);
m.X = 99;                                   // record struct の位置指定プロパティは set 可能
Console.WriteLine($"record struct は書き換え可: {m}");

var ro = new PointReadonlyRecordStruct(1, 2);
Console.WriteLine($"readonly record struct: {ro}(ro.X = 99 はコンパイルエラー)");

var mr = new MutableRecord { Name = "a" };
mr.Name = "b";                              // record でも set を書けば可変
Console.WriteLine($"set を持つ record: {mr}");

record MutableRecord
{
    public string Name { get; set; } = "";
}

実行結果:

record struct は書き換え可: PointRecordStruct { X = 99, Y = 2 }
readonly record struct: PointReadonlyRecordStruct { X = 1, Y = 2 }(ro.X = 99 はコンパイルエラー)
set を持つ record: MutableRecord { Name = b }
型 位置指定プロパティ 代入 p.X = 10
record get; init; コンパイルエラー(CS8852)
record struct get; set; できる
readonly record struct get; init; コンパイルエラー(CS8852)
プロパティを自分で書いたrecord 書いたとおり set なら可、init なら不可

「recordだから不変」ではなく、「位置指定のrecord(record class)とreadonly record structは、プロパティがinitになる」 と覚えると正確です。record structを不変にしたいときは readonly を付けます。

実際のコンパイルエラー文

次のコードを dotnet build したときのエラーです(.NET SDK 8.0.131)。

var r = new PointRecord(1, 2);
r.X = 10;

var ro = new PointRo(1, 2);
ro.X = 10;

var s1 = new PointStruct { X = 1 };
var s2 = new PointStruct { X = 1 };
Console.WriteLine(s1 == s2);

var c = new PointClass();
var c2 = c with { X = 1 };

record PointRecord(int X, int Y);
readonly record struct PointRo(int X, int Y);
struct PointStruct { public int X { get; set; } }
class PointClass { public int X { get; init; } }
Program.cs(2,1): error CS8852: Init-only property or indexer 'PointRecord.X' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.
Program.cs(5,1): error CS8852: Init-only property or indexer 'PointRo.X' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.
Program.cs(9,19): error CS0019: Operator '==' cannot be applied to operands of type 'PointStruct' and 'PointStruct'
Program.cs(12,10): error CS8858: The receiver type 'PointClass' is not a valid record type and is not a struct type.
エラー 意味 直し方
CS8852 init のプロパティに、初期化のあとで代入しようとした r with { X = 10 } で新しいインスタンスを作る
CS0019 通常のstructに == が定義されていない Equals を使う、record struct にする、演算子を定義する
CS8858 classにwith式を使った record にする

可変のrecordをHashSetに入れるときの注意

recordのハッシュ値は中身から計算されます。set を持つrecordをコレクションに入れたあとで書き換えると、探せなくなることを確かめました。

var set = new HashSet<Mutable>();
var mu = new Mutable { Name = "a" };
set.Add(mu);
mu.Name = "b";
Console.WriteLine($"書き換え後に Contains: {set.Contains(mu)}");

record Mutable { public string Name { get; set; } = ""; }
書き換え後に Contains: False

キーや集合の要素として使うrecordは、init のままにしておくのが安全です。

recordの継承と等価性

recordは、ほかのrecordを継承できます。等価性の比較には 「実行時の型が同じか」 も含まれます。

Person p = new Person("sato", 20);
Person st = new Student("sato", 20, "S01");
Person st2 = new Student("sato", 20, "S01");
Console.WriteLine($"Person と Student(同じ名前・年齢): {p == st}");
Console.WriteLine($"Student 同士: {st == st2}");
Console.WriteLine(st);

Person changed = st with { Age = 21 };
Console.WriteLine($"基底型の変数で with: {changed}(実行時の型: {changed.GetType().Name})");

record Person(string Name, int Age);
record Student(string Name, int Age, string StudentId) : Person(Name, Age);

実行結果:

Person と Student(同じ名前・年齢): False
Student 同士: True
Student { Name = sato, Age = 20, StudentId = S01 }
基底型の変数で with: Student { Name = sato, Age = 21, StudentId = S01 }(実行時の型: Student)
  • 変数の型が Person でも、中身が Person と Student なら等しくなりません。
  • 変数の型が Person でも、ToString とwith式は実行時の型(Student)で動き、StudentId も保たれます。

継承の組み合わせで書けないものも、ビルドして確かめました。

class Base { }
record Derived(int X) : Base;          // record が class を継承
record R(int X);
class C : R { public C() : base(1) { } }   // class が record を継承
Program.cs(2,25): error CS8864: Records may only inherit from object or another record
Program.cs(4,11): error CS8865: Only records may inherit from records.

(2行目ではCS8864に続いてCS0115も3件表示されますが、原因は同じなので省いています。)

組み合わせ 可否
recordがrecordを継承 できる
recordがclassを継承 できない(CS8864)
classがrecordを継承 できない(CS8865)
record structの継承 できない(structは継承なし。インターフェースの実装は可)
継承させたくない sealed record と書く

class・struct・recordの選び方

判断のフロー

flowchart TD
    A[新しい型を作る] --> B{値が同じなら<br>同じものとして扱いたいか}
    B -- いいえ:個体を区別したい<br>状態が変化する --> C[class]
    B -- はい:データを運ぶ型 --> D{小さくて<br>大量に作るか<br>数値の組など}
    D -- いいえ --> E{継承が必要か}
    E -- はい --> F[record]
    E -- いいえ --> G[sealed record<br>または record]
    D -- はい --> H{作ったあとに<br>書き換えるか}
    H -- いいえ --> I[readonly record struct]
    H -- はい --> J[record struct]

用途ごとの目安

用途 向いている型 理由
APIのリクエスト・レスポンス、設定値、画面に渡すデータ record 値で比べられ、ToString で中身が読め、with式で一部だけ変えられる
複数の値を組み合わせたDictionaryのキー record または readonly record struct Equals と GetHashCode が用意される
座標・範囲・数量と単位など、小さな値の組 readonly record struct 値型でコピーが軽く、不変
IDで識別し、状態が変わっていくもの(会員、注文、タスクなど) class 「同じID=同じもの」であり、全項目の一致で比べる対象ではない
処理を担当するサービス、データベース接続を持つもの class 値として比べる意味がない
既存のstructに == と ToString を足したい record struct 1行で用意される

record(参照型)とrecord struct(値型)の違い

var p1 = new PointRs(1, 2);
var p2 = p1;          // 値のコピー
p2.X = 100;
Console.WriteLine($"record struct の代入はコピー: p1={p1} p2={p2}");

var q1 = new Point(1, 2);
var q2 = q1;          // 参照のコピー
Console.WriteLine($"record の代入は同じ参照: {ReferenceEquals(q1, q2)}");

record Point(int X, int Y);
record struct PointRs(int X, int Y);

実行結果:

record struct の代入はコピー: p1=PointRs { X = 1, Y = 2 } p2=PointRs { X = 100, Y = 2 }
record の代入は同じ参照: True
比べる点 record record struct
代入・引数渡し 参照を渡す 値全体をコピーする
null なり得る(Point?) ならない(PointRs? はNullable型)
位置指定プロパティ init set(readonly を付けると init)
継承 できる できない
向いているサイズ 項目が多くてもよい 項目が少なく小さいもの

機能ごとのC#バージョン表(9〜14)

Microsoft Learnの「C#の歴史」と「言語のバージョン管理」のページ(今回開いて確認)をもとに、recordに関わる機能を整理しました。

C# 既定になる.NET recordに関わる機能 この記事での確認
9 .NET 5 record(参照型)、init 専用セッター、with式 .NET 8で実行
10 .NET 6 record struct、readonly record struct、structと匿名型でのwith式、recordの ToString に sealed を付けられる .NET 8で実行(sealed ToString はビルドが通ることのみ確認)
11 .NET 7 required メンバー .NET 8で実行
12 .NET 8 classとstructのプライマリコンストラクター、コレクション式 プライマリコンストラクターを.NET 8で実行
13 .NET 9 recordに直接関わる追加は、バージョン履歴の一覧には見当たらない 未実行
14 .NET 10 field キーワード(プロパティの裏側のフィールドを書かずに検証を入れられる) 未実行

C# 14の field キーワードを使うと、「with式でも入力チェックは働く」の例で書いた _c のようなフィールドを自分で用意しなくて済むようになります(一覧での確認のみで、今回は実行していません)。

C# 12のプライマリコンストラクターとrecordの位置指定は、見た目は似ていても動きが違います。 classの class P(int x) はプロパティも等価性も作りません。データ用の型を短く書きたいときは、recordを選びます。

JSONシリアライズ時の注意(System.Text.Json)

recordは、標準のSystem.Text.Jsonでそのまま変換できます。.NET 8.0.31で確かめた結果と、つまずきやすい点をまとめます。

基本:変換して戻す

using System.Text.Json;
using System.Text.Json.Serialization;

var member = new Member(1, "sato", new DateOnly(2026, 10, 3));
string json = JsonSerializer.Serialize(member);
Console.WriteLine(json);
var back = JsonSerializer.Deserialize<Member>(json);
Console.WriteLine($"{back} / 元と等しい: {member == back}");

record Member(int Id, string Name, DateOnly Joined);

実行結果:

{"Id":1,"Name":"sato","Joined":"2026-10-03"}
Member { Id = 1, Name = sato, Joined = 10/03/2026 } / 元と等しい: True

位置指定のrecordは引数つきのコンストラクターしか持ちませんが、System.Text.Jsonはそのコンストラクターを使って復元します。戻したものが元と == で等しいと確かめられるのは、recordならではです(Joined の表示形式は実行環境のカルチャ設定によって変わります)。

注意1:プロパティ名の大文字・小文字

// 既定のオプションで、小文字始まりのJSONを読む
Console.WriteLine(JsonSerializer.Deserialize<Member>("""{"id":2,"name":"kato","joined":"2026-01-15"}"""));

// Web向けの既定値(キャメルケース、大文字小文字を区別しない)
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
Console.WriteLine(JsonSerializer.Serialize(member, web));
Console.WriteLine(JsonSerializer.Deserialize<Member>("""{"id":2,"name":"kato","joined":"2026-01-15"}""", web));

実行結果:

Member { Id = 0, Name = , Joined = 01/01/0001 }
{"id":1,"name":"sato","joined":"2026-10-03"}
Member { Id = 2, Name = kato, Joined = 01/15/2026 }

既定のオプションでは名前の大文字・小文字を区別するため、id は Id に結び付かず、例外にならないまま、すべて既定値のrecordができます。 Name は string(null非許容)と宣言していても、中身はnullです。JavaScriptとやり取りするJSONでは JsonSerializerDefaults.Web を指定するのが手軽です。

注意2:項目が足りないJSON

Console.WriteLine(JsonSerializer.Deserialize<Member>("""{"Id":3}"""));

try
{
    JsonSerializer.Deserialize<StrictMember>("""{"Id":3}""");
}
catch (JsonException e)
{
    Console.WriteLine($"JsonException: {e.Message}");
}

record StrictMember
{
    public required int Id { get; init; }
    public required string Name { get; init; }
}

実行結果:

Member { Id = 3, Name = , Joined = 01/01/0001 }
JsonException: JSON deserialization for type 'StrictMember' was missing required properties, including the following: Name

位置指定のrecordでは、足りない項目は既定値のまま通ります。必須にしたい項目は required を付けたプロパティで書くと、足りないときに JsonException で知らせてくれます。

注意3:JSON側の名前を変える

位置指定パラメーターに属性を付けるときは、[property: ...] と書いて「プロパティに付ける」ことを示します。

var renamed = new Item(1, "pen");
Console.WriteLine(JsonSerializer.Serialize(renamed));
Console.WriteLine(JsonSerializer.Deserialize<Item>("""{"item_id":5,"item_name":"note"}"""));

record Item(
    [property: JsonPropertyName("item_id")] int Id,
    [property: JsonPropertyName("item_name")] string Name);

実行結果:

{"item_id":1,"item_name":"pen"}
Item { Id = 5, Name = note }

注意4:継承したrecordは、指定した型の分だけ出力される

Console.WriteLine(JsonSerializer.Serialize<Person>(new Student("sato", 20, "S01")));
Console.WriteLine(JsonSerializer.Serialize<object>(new Student("sato", 20, "S01")));

実行結果:

{"Name":"sato","Age":20}
{"StudentId":"S01","Name":"sato","Age":20}

Person として変換すると、StudentId は出力されません。派生型の項目も出したいときは、object として渡すか、基底のrecordに JsonDerivedType 属性を付けて派生型を登録します(JsonDerivedType は今回未実行です)。

注意5:record structも変換できる

Console.WriteLine(JsonSerializer.Serialize(new PointRecordStruct(1, 2)));
Console.WriteLine(JsonSerializer.Deserialize<PointRecordStruct>("""{"X":3,"Y":4}"""));
Console.WriteLine(JsonSerializer.Deserialize<PointReadonlyRecordStruct>("""{"X":3,"Y":4}"""));

実行結果:

{"X":1,"Y":2}
PointRecordStruct { X = 3, Y = 4 }
PointReadonlyRecordStruct { X = 3, Y = 4 }

.NET 8では、record structもreadonly record structも、追加の属性なしで復元できました。

JSONの注意点まとめ

注意点 起きること 対応
名前の大文字・小文字 既定では区別され、合わない項目は既定値になる JsonSerializerDefaults.Web か PropertyNameCaseInsensitive = true
項目の不足 既定値(参照型はnull)のまま復元される required を付ける
JSON側の名前の変更 位置指定パラメーターに直接は付けられない [property: JsonPropertyName("...")]
継承したrecord 指定した型のプロパティだけ出力 object として渡す、JsonDerivedType を使う
コレクションを持つrecord 復元したものは元と == で等しくならない Equals を自分で書く(前述)

Microsoft Learnの「変更できない型とプロパティを使用する」のページ(今回開いて確認)には、recordがシリアライズ・デシリアライズの両方に対応していること、コンストラクターの引数名とプロパティ名の照合は大文字・小文字を区別しないことが書かれています。

つまずきやすい点のチェックリスト

チェック項目 起きること こうするとよい
recordに List<T> や配列を持たせて == で比べている 中身が同じでもFalse Equals と GetHashCode を書く
with式でコピーしたあと、リストに追加している 元のインスタンスのリストも変わる with式の中で新しいリストを渡す
record struct を不変だと思っている 位置指定プロパティは書き換えられる readonly record struct にする
IDで識別するものをrecordにしている 1項目でも違うと「別物」になる classにしてIDで比べる
set を持つrecordをHashSetやDictionaryのキーにしている 書き換え後に見つからない キーにするrecordは init にする
基底型と派生型のrecordを比べている 値が同じでもFalse 型まで含めて比べるしくみと理解する
JSONのキーが小文字始まり 既定値のrecordが返る JsonSerializerDefaults.Web を使う

よくある質問

Q. recordとclassはどう使い分けますか? A. 「値がすべて同じなら同じもの」と言えるデータ(受け渡し用のデータ、設定、検索条件、複合キーなど)はrecordが向いています。IDで識別されて状態が変わっていくものや、処理を担当するものはclassが向いています。迷ったら「2つのインスタンスの全項目が同じとき、同じものとして扱ってよいか」を考えると判断しやすくなります。

Q. recordは必ず不変ですか? A. いいえ。位置指定の record と readonly record struct はプロパティが init になりますが、record struct の位置指定プロパティは set で、書き換えられます。また、set を自分で書いたrecordも可変です。さらに、プロパティが init でも、メンバーのリストに要素を追加することはできます。

Q. with式はディープコピーですか? A. 浅いコピーです。各メンバーの値をそのまま写すので、リストなどの参照型メンバーは元と同じインスタンスを指します。この記事の実行結果でも、コピー側のリストに追加した要素が元にも現れました。必要に応じて、with式の中で新しいコレクションを作って渡します。

Q. record と record class は違うものですか? A. 同じものです。C# 10でrecord structが加わったので、参照型のほうをはっきり示したいときに record class と書けるようになりました。record とだけ書くと参照型になります。

まとめ

  • recordは値で等しさを比べるデータ用の型。 ==、Equals、GetHashCode、ToString、分解、with式が1行で用意される。
  • classは参照、recordは値で比べる。 同じ値の2つのインスタンスで、classの == はFalse、recordはTrueになる。
  • List<T> や配列をメンバーに持つと、中身が同じでも等しくならない。 必要なら Equals と GetHashCode を書く。
  • with式は浅いコピー。 参照型のメンバーは元と共有されるので、コレクションは新しく作って渡す。
  • 不変になるのは、位置指定の record と readonly record struct。 record struct の位置指定プロパティは書き換えられる。
  • 小さな値の組は readonly record struct、データを運ぶ型は record、状態を持つものは class。
  • JSONでは大文字・小文字と項目の不足に注意。 JsonSerializerDefaults.Web と required で意図どおりに扱える。

参考・出典

  • Microsoft Learn「レコード(C# リファレンス)」 https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/builtin-types/record
  • Microsoft Learn「The history of C#」 https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-version-history
  • Microsoft Learn「C# language versioning」 https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-versioning
  • Microsoft Learn「Use immutable types and properties(System.Text.Json)」 https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/immutability

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?