【C#】record型完全ガイド|自動生成メンバー・with式・record struct・継承・DDD活用まで

【C#】レコード型(record)の基本とクラス/構造体との違い C#

C# 9 で導入された record は「値比較が自動で使える便利な型」というだけではありません。コンパイラが何を自動生成するのか・with 式の内部実装・継承時の等値判定の仕組み・record struct(C# 10+)の使い分け・ミュータブルレコードの設計・JSON シリアライズ挙動・DDD のバリューオブジェクトへの応用まで、実務で必ず直面する深い話題が多数あります。

本記事ではレコード型の内部構造から実践的な設計パターンまで体系的に解説します。init 専用プロパティの基礎はinit専用プロパティの使い方を、値型と参照型の基礎は値型と参照型完全ガイドを参照してください。

スポンサーリンク

record の4つの構文

C# の record には4種類の宣言方法があります。まず全体像を把握してから詳細を見ていきます。

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))が使われます。

with 式とコピーコンストラクタの仕組み
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
with 式はシャローコピー
デフォルトのコピーコンストラクタはシャローコピーです。プロパティが List<T> や配列などの参照型の場合、with 式で得たコピーでも同じ参照を持ちます(コピー先で変更すると元も影響を受ける)。ディープコピーが必要な場合は、上記のようにコピーコンストラクタを手動でオーバーライドしてください。

positional 構文 vs 標準プロパティ構文

観点 positional 構文 標準プロパティ構文
記述量 1行で定義可能 各プロパティを手動で書く
Deconstruct 自動生成 生成されない(手動実装が必要)
デフォルト値 設定できない(制約) 設定可能(= "default"
バリデーション コンストラクタ内でのみ可能 init アクセサ内で可能
追加コンストラクタ 定義可能(全プロパティを初期化する必要あり) 通常通り定義
カスタムコンストラクタ this() 委譲必須 制限なし
positional 構文でのバリデーションとデフォルト値
// 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 プロパティ(型情報を返す)によって保護されています。

record 継承と 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 はクラスを継承できない(object を除く)
recordclass(非レコード)を継承することも、非レコードクラスから継承されることもできません。継承するのは 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 vs readonly record 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 アクセサを明示すればミュータブルにできます。ただし「等値比較は値ベース」なのに「値を変更できる」という設計は混乱を招きやすいため、用途を絞って使います。

ミュータブルな record(標準プロパティ構文)
// 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(変更後はセットで見つからない)
ミュータブルレコードを Dictionary/HashSet のキーに使わない
record は GetHashCode() をプロパティ値から計算します。ミュータブルレコードをコレクションのキーにした後でプロパティを変更すると、ハッシュコードが変わってそのキーで検索できなくなります。キーとして使う場合は readonly record struct や不変 record class(init のみ)を使ってください。

パターンマッチングと Deconstruct

positional record は位置パターンと組み合わせることで、簡潔な条件分岐を書けます。

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) をオーバーライドして出力をカスタマイズできます。

PrintMembers のオーバーライド
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 とのシリアライズ

record の 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 のデシリアライズ注意点
positional record(public record Foo(int X, int Y))は引数なしコンストラクタを持たないため、System.Text.Json のデフォルト設定ではデシリアライズに失敗します。解決策は① [JsonConstructor] 属性をポジショナルコンストラクタに付ける、② 標準プロパティ構文の record を使う、の2択です。Entity Framework Core でも同様に引数なしコンストラクタが必要な場合があります。

実践活用 — DDD バリューオブジェクトとドメインイベント

バリューオブジェクトとして record を使う
// ① 金額バリューオブジェクト
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 を使う
// ③ ドメインイベント: 発生した事実を不変の 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)

よくある質問

Qrecord と class + IEquatable<T> はどちらを使うべきですか?
A「値比較が自動で使えるかどうか」が分岐点です。データを保持するだけの型(DTO・バリューオブジェクト・イベント・設定など)は record が適しています。振る舞いを持つサービスクラス・リポジトリ・UI コントロールなど「アイデンティティ(参照)が重要なオブジェクト」は通常の class を使います。
Qrecord classrecord struct はどう使い分けますか?
A一般に record class を使い、① データが小さい(数プロパティ)、② 値型のセマンティクスが自然(座標・色・サイズ)、③ 大量生成してヒープ圧迫を避けたい、のいずれかに当てはまる場合に readonly record struct を検討してください。record struct(ミュータブル)は用途が限定的です。
Qrecord を継承した場合、親と子のインスタンスは等しくなりますか?
Aなりません。EqualityContract プロパティが typeof(Circle)(子)と typeof(Shape)(親)で異なるため、たとえプロパティの値が一致していても Equals()false を返します。これは「Circle と Shape は概念的に別物」というデフォルトの正しい挙動です。同じ型同士のみ等しいとみなされます。
Qwith 式のパフォーマンスは?
Awith 式はコピーコンストラクタを呼ぶため、毎回新しいインスタンスが生成されます(参照型の 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専用プロパティの使い方を、クラスとオブジェクトの基礎はクラスとオブジェクト完全ガイドを参照してください。