C# 9 で導入された record は「値比較が自動で使える便利な型」というだけではありません。コンパイラが何を自動生成するのか・with 式の内部実装・継承時の等値判定の仕組み・record struct(C# 10+)の使い分け・ミュータブルレコードの設計・JSON シリアライズ挙動・DDD のバリューオブジェクトへの応用まで、実務で必ず直面する深い話題が多数あります。
本記事ではレコード型の内部構造から実践的な設計パターンまで体系的に解説します。init 専用プロパティの基礎はinit専用プロパティの使い方を、値型と参照型の基礎は値型と参照型完全ガイドを参照してください。
record の4つの構文
C# の record には4種類の宣言方法があります。まず全体像を把握してから詳細を見ていきます。
// ① positional record class(最も簡潔。C# 9+)
public record Point(double X, double Y);
// ② 標準プロパティ構文の record class(C# 9+)
public record PersonRecord
{
public string Name { get; init; } = "";
public int Age { get; init; }
}
// ③ record struct(C# 10+)— 値型
public record struct Size(int Width, int Height);
// ④ readonly record struct(C# 10+)— 不変値型
public readonly record struct Color(byte R, byte G, byte B);
// 使い方
var p1 = new Point(1.0, 2.0);
var p2 = new Point(1.0, 2.0);
Console.WriteLine(p1 == p2); // True(値比較)
Console.WriteLine(p1); // Point { X = 1, Y = 2 }(自動 ToString)
var (x, y) = p1; // Deconstruct(自動生成)
Console.WriteLine($"x={x}, y={y}"); // x=1, y=2
var p3 = p1 with { Y = 5.0 }; // with 式(非破壊的コピー)
Console.WriteLine(p3); // Point { X = 1, Y = 5 }
コンパイラが自動生成するメンバー
positional record を1行宣言するだけで、コンパイラは次のメンバーをすべて自動生成します。
| 自動生成メンバー | 内容 | 備考 |
|---|---|---|
init プロパティ |
各パラメーターに対応する { get; init; } |
標準構文では手動で書く |
| コンストラクタ | 全パラメーターを受け取るコンストラクタ | positional 構文のみ |
Deconstruct() |
パラメーターを変数に分解するメソッド | positional 構文のみ |
Equals(T) / Equals(object?) |
すべてのプロパティの値を比較 | EqualityContract も比較 |
== / != 演算子 |
Equals() を委譲 |
|
GetHashCode() |
すべてのプロパティのハッシュを結合 | |
ToString() |
TypeName { Prop1 = val1, Prop2 = val2, ... } 形式 |
PrintMembers() を内部で呼ぶ |
PrintMembers(StringBuilder) |
ToString() の内部実装。Prop=Value を追記 |
オーバーライド可 |
| コピーコンストラクタ | protected record(record original). with 式に使われる |
継承時に重要 |
EqualityContract |
型情報を返す protected virtual Type プロパティ |
継承レコードの等値判定に使われる |
public record Product(string Name, decimal Price, int Stock);
var a = new Product("MacBook", 198000m, 10);
var b = new Product("MacBook", 198000m, 10);
var c = new Product("iPad", 98000m, 5);
// 値比較(Equals / ==)
Console.WriteLine(a == b); // True
Console.WriteLine(a == c); // False
Console.WriteLine(a.Equals(b)); // True
// GetHashCode: 同じ値なら同じハッシュ
Console.WriteLine(a.GetHashCode() == b.GetHashCode()); // True
// ToString / PrintMembers
Console.WriteLine(a);
// Product { Name = MacBook, Price = 198000, Stock = 10 }
// Deconstruct
var (name, price, stock) = a;
Console.WriteLine($"{name}: {price:C} x{stock}");
// MacBook: ¥198,000 x10
// Dictionary / HashSet でのキーとして使用可能
var dict = new Dictionary<Product, string>();
dict[a] = "在庫あり";
Console.WriteLine(dict[b]); // "在庫あり"(a と b は等価なので同じキー)
with 式の内部実装 — コピーコンストラクタ経由
with 式は元のオブジェクトを変更せず、指定したプロパティだけを差し替えた新しいインスタンスを返します。内部ではコピーコンストラクタ(protected record(record original))が使われます。
public record Config(string Host, int Port, bool UseSsl);
var defaultConfig = new Config("localhost", 5432, false);
// with 式: 一部を差し替えた新しいインスタンス
var prodConfig = defaultConfig with { Host = "db.prod.example.com", UseSsl = true };
var testConfig = defaultConfig with { Host = "db.test.example.com", Port = 5433 };
Console.WriteLine(defaultConfig); // Config { Host = localhost, Port = 5432, UseSsl = False }
Console.WriteLine(prodConfig); // Config { Host = db.prod.example.com, Port = 5432, UseSsl = True }
Console.WriteLine(testConfig); // Config { Host = db.test.example.com, Port = 5433, UseSsl = False }
// 元のインスタンスは変更されない(非破壊的)
Console.WriteLine(defaultConfig.Host); // localhost(変わっていない)
// with 式の展開(コンパイラが生成するコード):
// var prodConfig = new Config(defaultConfig); // コピーコンストラクタ
// prodConfig = prodConfig with { Host = ..., UseSsl = ... };
// ↑ 実際は init セッターを呼ぶ
// コピーコンストラクタを手動定義してシャローコピーの挙動をカスタマイズ
public record DeepConfig(string Host, List<string> Tags)
{
// コピーコンストラクタをオーバーライド(with 式で呼ばれる)
protected DeepConfig(DeepConfig original) : this(
original.Host,
new List<string>(original.Tags) // リストをディープコピー
) { }
}
var cfg1 = new DeepConfig("localhost", new List<string> { "tag1", "tag2" });
var cfg2 = cfg1 with { Host = "prod" }; // カスタムコピーコンストラクタが呼ばれる
cfg2.Tags.Add("tag3");
Console.WriteLine(cfg1.Tags.Count); // 2(ディープコピーなので影響なし)
Console.WriteLine(cfg2.Tags.Count); // 3
デフォルトのコピーコンストラクタはシャローコピーです。プロパティが
List<T> や配列などの参照型の場合、with 式で得たコピーでも同じ参照を持ちます(コピー先で変更すると元も影響を受ける)。ディープコピーが必要な場合は、上記のようにコピーコンストラクタを手動でオーバーライドしてください。positional 構文 vs 標準プロパティ構文
| 観点 | positional 構文 | 標準プロパティ構文 |
|---|---|---|
| 記述量 | 1行で定義可能 | 各プロパティを手動で書く |
| Deconstruct | 自動生成 | 生成されない(手動実装が必要) |
| デフォルト値 | 設定できない(制約) | 設定可能(= "default") |
| バリデーション | コンストラクタ内でのみ可能 | init アクセサ内で可能 |
| 追加コンストラクタ | 定義可能(全プロパティを初期化する必要あり) | 通常通り定義 |
| カスタムコンストラクタ | this() 委譲必須 |
制限なし |
// positional 構文のバリデーション
public record Temperature(double Celsius)
{
// コンパクト記法(Compact positional constructor)
public double Celsius { get; init; } = Celsius >= -273.15
? Celsius
: throw new ArgumentOutOfRangeException(nameof(Celsius), "絶対零度以下は不正です");
public double Fahrenheit => Celsius * 9 / 5 + 32;
public double Kelvin => Celsius + 273.15;
}
var t = new Temperature(100);
Console.WriteLine(t.Fahrenheit); // 212
// var bad = new Temperature(-300); // ArgumentOutOfRangeException
// 標準プロパティ構文: デフォルト値を設定
public record ApiConfig
{
public string BaseUrl { get; init; } = "https://api.example.com";
public int Timeout { get; init; } = 30;
public string? ApiKey { get; init; }
public int MaxRetry { get; init; } = 3;
}
var cfg = new ApiConfig { ApiKey = "my-key" };
Console.WriteLine(cfg.BaseUrl); // https://api.example.com(デフォルト)
Console.WriteLine(cfg.Timeout); // 30(デフォルト)
Console.WriteLine(cfg.MaxRetry); // 3(デフォルト)
record の継承
record は record だけを継承できます(通常クラスからの継承は不可。ただし object は除く)。継承チェーンでの等値判定は EqualityContract プロパティ(型情報を返す)によって保護されています。
// 基底レコード
public record Shape(string Color);
// 派生レコード
public record Circle(string Color, double Radius) : Shape(Color);
public record Rectangle(string Color, double Width, double Height) : Shape(Color);
var c1 = new Circle("Red", 5.0);
var c2 = new Circle("Red", 5.0);
var s1 = new Shape("Red");
// 同じ型、同じ値 → 等しい
Console.WriteLine(c1 == c2); // True
// 異なる型 → EqualityContract が異なるので等しくない
Console.WriteLine(c1.Equals(s1)); // False(Circle と Shape は異なる)
// EqualityContract = typeof(Circle) vs typeof(Shape) なので不一致
// ToString も型名が出る
Console.WriteLine(c1); // Circle { Color = Red, Radius = 5 }
Console.WriteLine(s1); // Shape { Color = Red }
// sealed record: これ以上派生できない
public sealed record Point3D(double X, double Y, double Z) : Shape("Transparent");
// abstract record: 直接インスタンス化できない
public abstract record Animal(string Name);
public record Dog(string Name, string Breed) : Animal(Name);
var dog = new Dog("Pochi", "Shiba");
Console.WriteLine(dog); // Dog { Name = Pochi, Breed = Shiba }
// var a = new Animal("X"); // コンパイルエラー: abstract
record は class(非レコード)を継承することも、非レコードクラスから継承されることもできません。継承するのは record 同士のみです。既存クラス階層に record を混ぜたい場合は、record を独立した型として設計し、アダプターパターンでつなぐ方法を検討してください。record struct と readonly record struct(C# 10+)
record struct は値型の record です。スタック上に確保されボクシングなしで扱えるため、小さいデータの集合に適しています。
| 種類 | 型種別 | ミュータブル | 自動生成 | 主な用途 |
|---|---|---|---|---|
record class |
参照型 | 不可(init) | 全メンバー | DTOs・ドメインイベント・API レスポンス |
record struct |
値型 | 可(set あり) | 全メンバー | 小さいデータの一時的な集合 |
readonly record struct |
値型(不変) | 不可(init) | 全メンバー | 座標・色・サイズなど不変の値 |
class(参考) |
参照型 | 可 | 手動 | サービス・ビジネスロジック |
struct(参考) |
値型 | 可 | 最小限 | 低レベル・パフォーマンス重視 |
// record struct: 値型だが set プロパティも使える(ミュータブル可)
public record struct MutablePoint(int X, int Y);
var mp = new MutablePoint(1, 2);
mp.X = 10; // OK: record struct はデフォルトで set あり
Console.WriteLine(mp); // MutablePoint { X = 10, Y = 2 }
// readonly record struct: 値型で完全不変(init のみ)
public readonly record struct ImmutablePoint(double X, double Y)
{
public double Distance => Math.Sqrt(X * X + Y * Y);
}
var ip = new ImmutablePoint(3.0, 4.0);
// ip.X = 5.0; // コンパイルエラー: readonly
Console.WriteLine(ip.Distance); // 5
// ボクシングなしで使える
ImmutablePoint[] points = { new(0, 0), new(1, 0), new(0, 1) };
// → int[] のように連続したメモリに値として格納される
// パフォーマンス比較(大量データでの差)
// record class: ヒープ確保 + GC 対象
// readonly record struct: スタック(または配列の連続領域)に格納
// with 式は record struct でも使える
var ip2 = ip with { X = 5.0 };
Console.WriteLine(ip2); // ImmutablePoint { X = 5, Y = 4 }
// Deconstruct も自動生成
var (x, y) = ip;
Console.WriteLine($"({x}, {y})"); // (3, 4)
ミュータブルレコード(set を持つ record)
record class でも set アクセサを明示すればミュータブルにできます。ただし「等値比較は値ベース」なのに「値を変更できる」という設計は混乱を招きやすいため、用途を絞って使います。
// set を使ったミュータブルレコード
public record MutableConfig
{
public string Host { get; set; } = "localhost";
public int Port { get; set; } = 8080;
}
var cfg = new MutableConfig();
cfg.Host = "prod.example.com"; // OK: set あり
cfg.Port = 443;
Console.WriteLine(cfg); // MutableConfig { Host = prod.example.com, Port = 443 }
// 注意: 等値比較は「現在の値」で行われる
var a = new MutableConfig { Host = "A" };
var b = new MutableConfig { Host = "A" };
Console.WriteLine(a == b); // True
a.Host = "B";
Console.WriteLine(a == b); // False(値が変わった)
// HashSet / Dictionary でのキーにミュータブルレコードは危険
var set = new HashSet<MutableConfig> { a };
Console.WriteLine(set.Contains(a)); // True
a.Host = "A"; // ハッシュコードが変わる!
Console.WriteLine(set.Contains(a)); // False(変更後はセットで見つからない)
record は
GetHashCode() をプロパティ値から計算します。ミュータブルレコードをコレクションのキーにした後でプロパティを変更すると、ハッシュコードが変わってそのキーで検索できなくなります。キーとして使う場合は readonly record struct や不変 record class(init のみ)を使ってください。パターンマッチングと Deconstruct
positional record は位置パターンと組み合わせることで、簡潔な条件分岐を書けます。
public record Point(int X, int Y);
static string Classify(Point p) => p switch
{
(0, 0) => "原点",
(0, _) => "Y軸上",
(_, 0) => "X軸上",
(> 0, > 0) => "第1象限",
(< 0, > 0) => "第2象限",
(< 0, < 0) => "第3象限",
(> 0, < 0) => "第4象限",
_ => "不明",
};
Console.WriteLine(Classify(new Point(0, 0))); // 原点
Console.WriteLine(Classify(new Point(3, -2))); // 第4象限
// プロパティパターン(型名 + { プロパティ = パターン })
public record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Rectangle(string Color, double Width, double Height) : Shape(Color);
static double Area(Shape shape) => shape switch
{
Circle { Radius: var r } => Math.PI * r * r,
Rectangle { Width: var w, Height: var h } => w * h,
_ => throw new NotSupportedException($"未対応の形状: {shape.GetType().Name}")
};
Console.WriteLine(Area(new Circle("Red", 5))); // 78.539...
Console.WriteLine(Area(new Rectangle("Blue", 4, 3))); // 12
// Deconstruct を明示的に使う
var (x, y) = new Point(10, 20);
Console.WriteLine($"X={x}, Y={y}"); // X=10, Y=20
// 入れ子パターン
public record Order(string ProductName, Point DeliveryPoint);
var order = new Order("Book", new Point(3, 5));
if (order is { DeliveryPoint: (> 0, > 0) and { X: var ox } })
Console.WriteLine($"第1象限 X={ox}"); // 第1象限 X=3
ToString / PrintMembers のカスタマイズ
ToString() を直接オーバーライドするか、PrintMembers(StringBuilder) をオーバーライドして出力をカスタマイズできます。
using System.Text;
public record CreditCard(string CardNumber, string HolderName, int ExpiryYear)
{
// PrintMembers をオーバーライド: CardNumber をマスク表示
protected virtual bool PrintMembers(StringBuilder builder)
{
// 自動生成コードを手動で再現しながらマスク処理を追加
builder.Append($"CardNumber = ****-****-****-{CardNumber[^4..]}, ");
builder.Append($"HolderName = {HolderName}, ");
builder.Append($"ExpiryYear = {ExpiryYear}");
return true; // メンバーを出力した場合は true を返す
}
}
var card = new CreditCard("1234567890123456", "Taro Yamada", 2027);
Console.WriteLine(card);
// CreditCard { CardNumber = ****-****-****-3456, HolderName = Taro Yamada, ExpiryYear = 2027 }
// ToString を直接オーバーライドする方法(より単純)
public record Temperature2(double Celsius)
{
public override string ToString()
=> $"Temperature2 {{ Celsius = {Celsius:F1}, Fahrenheit = {Celsius * 9 / 5 + 32:F1} }}";
}
Console.WriteLine(new Temperature2(100));
// Temperature2 { Celsius = 100.0, Fahrenheit = 212.0 }
System.Text.Json とのシリアライズ
using System.Text.Json;
using System.Text.Json.Serialization;
// positional record: デシリアライズには引数なしコンストラクタが必要
// → [JsonConstructor] 属性でポジショナルコンストラクタを指定
[method: JsonConstructor]
public record UserDto(string Name, int Age, string? Email = null);
// 標準プロパティ構文: 引数なしコンストラクタがあるのでそのまま動作
public record ProductDto
{
public int Id { get; init; }
public string Name { get; init; } = "";
public decimal Price { get; init; }
}
// シリアライズ
var user = new UserDto("Alice", 30, "alice@example.com");
string json = JsonSerializer.Serialize(user);
Console.WriteLine(json);
// {"Name":"Alice","Age":30,"Email":"alice@example.com"}
// デシリアライズ
var user2 = JsonSerializer.Deserialize<UserDto>(json);
Console.WriteLine(user2); // UserDto { Name = Alice, Age = 30, Email = alice@example.com }
Console.WriteLine(user == user2); // True(値比較)
// null を省略したい場合
var options = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
var user3 = new UserDto("Bob", 25); // Email = null
Console.WriteLine(JsonSerializer.Serialize(user3, options));
// {"Name":"Bob","Age":25} ← Email が省略される
positional record(
public record Foo(int X, int Y))は引数なしコンストラクタを持たないため、System.Text.Json のデフォルト設定ではデシリアライズに失敗します。解決策は① [JsonConstructor] 属性をポジショナルコンストラクタに付ける、② 標準プロパティ構文の record を使う、の2択です。Entity Framework Core でも同様に引数なしコンストラクタが必要な場合があります。実践活用 — DDD バリューオブジェクトとドメインイベント
// ① 金額バリューオブジェクト
public record Money(decimal Amount, string Currency)
{
public decimal Amount { get; } = Amount >= 0
? Amount
: throw new ArgumentException("金額は0以上でなければなりません");
public string Currency { get; } = string.IsNullOrWhiteSpace(Currency)
? throw new ArgumentException("通貨コードは必須です")
: Currency.ToUpperInvariant();
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("異なる通貨同士は加算できません");
// with 式は { get; } プロパティには使えないため、コンストラクタを直接呼ぶ
return new Money(Amount + other.Amount, Currency);
}
public override string ToString() => $"{Amount:N0} {Currency}";
}
var price = new Money(1980m, "JPY");
var tax = new Money(198m, "JPY");
var total = price.Add(tax);
Console.WriteLine(total); // 2,178 JPY
// ② ID バリューオブジェクト(型安全な ID)
public record CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString("D");
}
public record OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public override string ToString() => Value.ToString("D");
}
// CustomerId を OrderId と混同できない(コンパイルエラーになる)
CustomerId cid = CustomerId.New();
// OrderId oid = cid; // コンパイルエラー: 型が違う
// ③ ドメインイベント: 発生した事実を不変の record で表現
public abstract record DomainEvent(DateTime OccurredAt);
public record OrderPlaced(
string OrderId,
string CustomerId,
Money TotalAmount,
DateTime OccurredAt) : DomainEvent(OccurredAt);
public record OrderShipped(
string OrderId,
string TrackingNumber,
DateTime OccurredAt) : DomainEvent(OccurredAt);
// 処理側でパターンマッチ
static string Describe(DomainEvent e) => e switch
{
OrderPlaced { OrderId: var oid, TotalAmount: var amount }
=> $"注文 {oid} が確定しました(合計: {amount})",
OrderShipped { OrderId: var oid, TrackingNumber: var tracking }
=> $"注文 {oid} が発送されました(追跡: {tracking})",
_ => $"不明なイベント: {e.GetType().Name}"
};
var events = new DomainEvent[]
{
new OrderPlaced("ORD-001", "CUST-42", new Money(5000m, "JPY"), DateTime.Now),
new OrderShipped("ORD-001", "TRACK-123456", DateTime.Now),
};
foreach (var ev in events)
Console.WriteLine(Describe(ev));
// 注文 ORD-001 が確定しました(合計: 5,000 JPY)
// 注文 ORD-001 が発送されました(追跡: TRACK-123456)
よくある質問
record が適しています。振る舞いを持つサービスクラス・リポジトリ・UI コントロールなど「アイデンティティ(参照)が重要なオブジェクト」は通常の class を使います。record class と record struct はどう使い分けますか?record class を使い、① データが小さい(数プロパティ)、② 値型のセマンティクスが自然(座標・色・サイズ)、③ 大量生成してヒープ圧迫を避けたい、のいずれかに当てはまる場合に readonly record struct を検討してください。record struct(ミュータブル)は用途が限定的です。EqualityContract プロパティが typeof(Circle)(子)と typeof(Shape)(親)で異なるため、たとえプロパティの値が一致していても Equals() は false を返します。これは「Circle と Shape は概念的に別物」というデフォルトの正しい挙動です。同じ型同士のみ等しいとみなされます。with 式はコピーコンストラクタを呼ぶため、毎回新しいインスタンスが生成されます(参照型の record class の場合はヒープアロケーション)。ループ内で大量に with を呼ぶとGC圧力になります。パフォーマンスクリティカルな場面では readonly record struct(スタック or 配列内に直接格納)を検討してください。まとめ
| 機能・概念 | ポイント |
|---|---|
| auto-generated メンバー | Equals・GetHashCode・==・ToString・PrintMembers・コピーコンストラクタ・Deconstruct・EqualityContract |
| positional 構文 | 1行で定義。Deconstruct 自動生成。デフォルト値は設定不可 |
| with 式 | コピーコンストラクタ経由の非破壊コピー。参照型プロパティはシャローコピー |
| EqualityContract | 継承チェーンで型が違えば等しくない仕組み |
| record 継承 | record 同士のみ可。sealed/abstract をサポート |
| record struct | 値型。デフォルトでミュータブル。キーに使う場合は注意 |
| readonly record struct | 値型 + 不変。座標・色・サイズなどに最適 |
| JSON シリアライズ | positional record には [JsonConstructor] が必要 |
| DDD 活用 | バリューオブジェクト・ドメインイベントとして最適 |
record のプロパティ設計に深く関わる init アクセサの詳細はinit専用プロパティの使い方を、クラスとオブジェクトの基礎はクラスとオブジェクト完全ガイドを参照してください。

