C# recordのwith式とは?コピー更新の仕組み・使い方・classとの違いをわかりやすく解説
はじめに
C#のrecordを学んでいると、よく一緒に登場するのがwith式です。
recordは、データを表現するために便利な型です。そしてwith式は、そのデータを「元の値を残したまま、一部だけ変更した新しい値」として作り直すための構文です。
たとえば、ユーザー情報を表すrecordがあるとします。
C#public record User(string Name, int Age);
このUserのNameはそのままで、Ageだけを変更した新しいオブジェクトを作りたい場合、with式を使うと次のように書けます。
C#var user1 = new User("Taro", 20);
var user2 = user1 with { Age = 21 };
user1の値は変更されません。user2として、Ageだけが21になった新しいUserが作られます。
この記事では、C#のrecordにおけるwith式について、基本的な使い方、内部の仕組み、classとの違い、実務での使いどころ、注意点までわかりやすく解説します。
1. C# recordのwith式とは?コピー更新をかんたんに理解する
1-1. with式は「元の値を残したまま一部だけ変更したコピーを作る」構文
C#のwith式は、既存のオブジェクトをもとにして、一部のプロパティだけを変更した新しいオブジェクトを作るための構文です。
たとえば、次のようなrecordがあるとします。
C#public record Product(string Name, int Price);
このProductの価格だけを変更したい場合、通常であれば新しいインスタンスを作り直します。
C#var product1 = new Product("Keyboard", 5000);
var product2 = new Product(product1.Name, 6000);
これでも動きますが、プロパティが増えると書く量が多くなります。
C#public record Product(
string Name,
int Price,
string Category,
bool IsActive
);
このような型でPriceだけ変更したい場合、すべての値を再指定するのは面倒です。
そこでwith式を使います。
C#var product1 = new Product("Keyboard", 5000, "PC", true);
var product2 = product1 with
{
Price = 6000
};
これにより、Name、Category、IsActiveはproduct1の値を引き継ぎ、Priceだけが6000になった新しいProductが作られます。
つまりwith式は、次のようなイメージです。
C#元の値 + 変更したい部分 = 新しい値
既存のデータを直接変更するのではなく、新しいデータとして作り直す点が重要です。
1-2. recordとwith式がセットで使われる理由
recordとwith式は相性がよい機能です。
recordは、値そのものを表すデータ型として使われることが多いです。たとえば、ユーザー情報、設定値、APIレスポンス、イベントデータ、コマンド情報などです。
このようなデータは、後から直接書き換えるよりも、必要な部分だけを変更した新しいデータとして扱う方が安全です。
C#public record UserProfile(
int Id,
string Name,
string Email
);
メールアドレスだけを変更する場合は、次のように書けます。
C#var current = new UserProfile(1, "Taro", "taro@example.com");
var updated = current with
{
Email = "new-taro@example.com"
};
currentは元の状態のまま残ります。updatedには新しいメールアドレスが入ります。
このように、recordは「データを値として扱う」ための型であり、with式は「値を安全にコピー更新する」ための構文です。そのため、recordとwith式はセットで説明されることが多いです。
1-3. まずは最小コードで動きを確認する
最小限のコードでrecordとwith式の動きを確認してみます。
C#public record Person(string Name, int Age);
var person1 = new Person("Alice", 30);
var person2 = person1 with
{
Age = 31
};
Console.WriteLine(person1);
Console.WriteLine(person2);
出力例は次のようになります。
C#Person { Name = Alice, Age = 30 }
Person { Name = Alice, Age = 31 }
person1はAge = 30のままです。person2はAge = 31になっています。
また、person1とperson2は別のインスタンスです。
C#Console.WriteLine(ReferenceEquals(person1, person2));
出力は次のようになります。
C#False
with式は、既存のオブジェクトを直接変更するものではありません。あくまで、コピーを作ってから指定したプロパティを変更する構文です。
2. C# recordの基本:with式を理解する前に押さえるポイント
2-1. recordとは何か
recordは、C# 9.0で導入されたデータ表現に向いた型です。
通常のclassと似ていますが、recordにはデータを扱いやすくするための機能が自動的に用意されます。
代表的な特徴は次のとおりです。
C#public record Customer(int Id, string Name);
このように書くだけで、主に次のような機能が使えます。
値に基づく等価比較、ToString()の見やすい出力、分解、コピー更新に使う仕組みなどが自動で提供されます。
たとえば、同じ値を持つ2つのrecordを比較してみます。
C#var customer1 = new Customer(1, "Taro");
var customer2 = new Customer(1, "Taro");
Console.WriteLine(customer1 == customer2);
出力は次のようになります。
C#True
通常のclassでは、特別な実装をしない限り、別インスタンス同士の比較は参照が同じかどうかで判断されます。一方、recordでは値が同じであれば等しいと判断されます。
この「値として扱いやすい」という特徴が、recordの大きなポイントです。
2-2. record classとrecord structの違い
recordには、大きく分けてrecord classとrecord structがあります。
C#public record class UserRecordClass(string Name, int Age);
public record struct UserRecordStruct(string Name, int Age);
record classは参照型です。通常のclassと同じように、変数にはオブジェクトへの参照が入ります。
C#var user1 = new UserRecordClass("Alice", 30);
var user2 = user1;
この場合、user1とuser2は同じインスタンスを参照します。
一方、record structは値型です。通常のstructと同じように、代入時には値がコピーされます。
C#var point1 = new Point(10, 20);
var point2 = point1;
record structは値型なので、小さな値のまとまりを表す場合に向いています。
ただし、実務ではrecord classの方が使われる場面は多いです。DTO、コマンド、イベント、設定値など、データを参照型として扱いたい場合にはrecord classが自然です。
なお、次のように単にrecordと書いた場合は、基本的にrecord classを意味します。
C#public record User(string Name, int Age);
これは次の書き方とほぼ同じ意味です。
C#public record class User(string Name, int Age);
2-3. イミュータブルなデータ表現に向いている理由
recordは、イミュータブルなデータ表現に向いています。
イミュータブルとは、作成後に値を変更しない、または変更しにくい性質のことです。
たとえば、次のようなrecordを考えます。
C#public record Order(
int Id,
string Status,
decimal TotalAmount
);
注文情報を直接変更するのではなく、状態が変わるたびに新しいOrderを作ると、過去の状態を壊さずに扱えます。
C#var order1 = new Order(1, "Created", 5000m);
var order2 = order1 with
{
Status = "Paid"
};
order1はCreatedのまま残ります。order2はPaidの新しい注文状態です。
このようにすると、どこかの処理で意図せず値が書き換わるリスクを減らせます。
特に、状態管理、非同期処理、テストコード、イベント履歴の管理などでは、元の値を壊さない設計が役立ちます。
2-4. initアクセサとsetアクセサの違い
recordを理解するうえで、initアクセサとsetアクセサの違いも重要です。
setは、オブジェクト作成後でもプロパティを書き換えられます。
C#public class User
{
public string Name { get; set; }
}
この場合、次のように後から変更できます。
C#var user = new User { Name = "Alice" };
user.Name = "Bob";
一方、initは、オブジェクト初期化時にだけ値を設定できます。
C#public record User
{
public string Name { get; init; }
}
次のような初期化は可能です。
C#var user = new User { Name = "Alice" };
しかし、作成後の通常代入はできません。
C#user.Name = "Bob"; // コンパイルエラー
ただし、with式の中ではinitプロパティを指定できます。
C#var updated = user with
{
Name = "Bob"
};
これは、with式が新しいオブジェクトを作る初期化処理として扱われるためです。
recordのプライマリコンストラクタで定義したプロパティは、基本的にinit可能なプロパティとして扱われます。
C#public record User(string Name, int Age);
そのため、次のようにwith式で更新できます。
C#var user1 = new User("Alice", 30);
var user2 = user1 with
{
Age = 31
};
3. with式の基本的な使い方
3-1. recordの一部プロパティだけ変更してコピーする
with式の基本形は次のとおりです。
C#var newObject = oldObject with
{
PropertyName = newValue
};
具体例を見てみましょう。
C#public record Employee(
int Id,
string Name,
string Department
);
var employee1 = new Employee(1, "Sato", "Sales");
var employee2 = employee1 with
{
Department = "Marketing"
};
employee2は、DepartmentだけがMarketingになった新しいEmployeeです。
C#Console.WriteLine(employee1);
Console.WriteLine(employee2);
出力例は次のようになります。
C#Employee { Id = 1, Name = Sato, Department = Sales }
Employee { Id = 1, Name = Sato, Department = Marketing }
employee1のDepartmentはSalesのままです。with式は元のオブジェクトを壊しません。
3-2. 複数プロパティを同時に変更する
with式では、複数のプロパティを同時に変更できます。
C#public record User(
string Name,
int Age,
string Email
);
var user1 = new User("Alice", 30, "alice@example.com");
var user2 = user1 with
{
Age = 31,
Email = "alice.new@example.com"
};
この場合、Nameはそのままで、AgeとEmailだけが変更されます。
C#Console.WriteLine(user2);
出力例は次のようになります。
C#User { Name = Alice, Age = 31, Email = alice.new@example.com }
変更したいプロパティだけを書けばよいので、プロパティ数が多いrecordでも読みやすくなります。
3-3. 変更しないプロパティは元の値が引き継がれる
with式で指定しなかったプロパティは、元のオブジェクトから引き継がれます。
C#public record Book(
string Title,
string Author,
int Price
);
var book1 = new Book("C# Guide", "Tanaka", 3000);
var book2 = book1 with
{
Price = 3500
};
book2のTitleとAuthorは、book1と同じです。
C#Console.WriteLine(book2.Title);
Console.WriteLine(book2.Author);
Console.WriteLine(book2.Price);
出力例は次のようになります。
C#C# Guide
Tanaka
3500
この「指定しなかった値はそのまま」という動きが、with式の大きな便利ポイントです。
3-4. with式で作られたオブジェクトは元のインスタンスとは別物
with式で作られたオブジェクトは、元のインスタンスとは別物です。
C#public record User(string Name, int Age);
var user1 = new User("Alice", 30);
var user2 = user1 with { Age = 31 };
Console.WriteLine(ReferenceEquals(user1, user2));
出力は次のようになります。
C#False
user1とuser2は別インスタンスです。
ただし、値の比較では注意が必要です。
C#var user3 = user1 with { };
Console.WriteLine(ReferenceEquals(user1, user3));
Console.WriteLine(user1 == user3);
出力例は次のようになります。
C#False
True
user3はuser1のコピーなので別インスタンスです。しかし、すべての値が同じなので、recordの値比較では等しいと判断されます。
つまり、recordでは「同じインスタンスか」と「同じ値か」を分けて考える必要があります。
4. with式の仕組み:内部では何が起きているのか
4-1. with式はコピーを作ってから指定プロパティを更新する
with式の基本的な流れは、次のように考えるとわかりやすいです。
C#var newObject = oldObjectのコピーを作る;
newObject.Property = newValue;
実際のコードでは次のように書きます。
C#var user2 = user1 with
{
Age = 31
};
これはイメージとしては、次のような処理に近いです。
C#var user2 = コピーされたuser1;
user2.Age = 31;
もちろん、実際にはコンパイラがrecordのコピー更新に必要な処理を使って展開します。
重要なのは、元のインスタンスを書き換えているわけではないという点です。
C#var user1 = new User("Alice", 30);
var user2 = user1 with { Age = 31 };
Console.WriteLine(user1.Age); // 30
Console.WriteLine(user2.Age); // 31
with式は、変更というより「コピー更新」と考えると理解しやすくなります。
4-2. record classではClone相当の処理が使われる
record classでwith式を使うと、内部ではコピーを作るための仕組みが使われます。
C#public record User(string Name, int Age);
このようなrecord classには、コピーを作るためのメンバーがコンパイラによって生成されます。with式はその仕組みを利用して、新しいインスタンスを作ります。
C#var user1 = new User("Alice", 30);
var user2 = user1 with { Age = 31 };
大まかなイメージは次のとおりです。
C#var user2 = user1のコピー;
user2.Age = 31;
record classは参照型なので、単に変数を代入するだけではコピーになりません。
C#var user2 = user1;
これは同じインスタンスを参照するだけです。
しかし、with式を使うと、元のインスタンスとは別のインスタンスが作られます。
C#var user2 = user1 with { };
この時点で、値が同じ別インスタンスが作られます。
4-3. record structでは値のコピーとして扱われる
record structは値型です。そのため、with式では値のコピーとして扱われます。
C#public record struct Point(int X, int Y);
var point1 = new Point(10, 20);
var point2 = point1 with
{
X = 30
};
point2は、Yは20のままで、Xだけが30になった新しい値です。
C#Console.WriteLine(point1);
Console.WriteLine(point2);
出力例は次のようになります。
C#Point { X = 10, Y = 20 }
Point { X = 30, Y = 20 }
record structは値型なので、record classとはコピーの考え方が少し異なります。ただし、with式を使う側から見ると「元の値を残したまま、一部だけ変更したコピーを作る」という感覚は同じです。
4-4. with式はディープコピーではなく浅いコピーである
with式で特に重要なのが、ディープコピーではなく浅いコピーであるという点です。
浅いコピーとは、オブジェクトが持っている参照型プロパティについて、参照先の中身まではコピーしないということです。
たとえば、次のようなrecordを考えます。
C#public record User(string Name, Address Address);
public record Address(string City);
この場合、Userをwith式でコピーしても、Addressを変更しなければ、同じAddressインスタンスを参照します。
C#var address = new Address("Tokyo");
var user1 = new User("Alice", address);
var user2 = user1 with
{
Name = "Bob"
};
Console.WriteLine(ReferenceEquals(user1.Address, user2.Address));
出力は次のようになります。
C#True
user1とuser2は別インスタンスですが、Addressプロパティは同じ参照を持っています。
これが浅いコピーです。
4-5. 参照型プロパティを持つ場合の注意点
参照型プロパティがイミュータブルであれば、浅いコピーでも問題になりにくいです。
C#public record Address(string City);
public record User(string Name, Address Address);
Addressもrecordで、値を書き換えずに扱うなら安全です。
一方、参照型プロパティが可変オブジェクトの場合は注意が必要です。
C#public record Team(string Name, List<string> Members);
var members = new List<string> { "Alice", "Bob" };
var team1 = new Team("A Team", members);
var team2 = team1 with
{
Name = "B Team"
};
team2.Members.Add("Charlie");
Console.WriteLine(string.Join(", ", team1.Members));
Console.WriteLine(string.Join(", ", team2.Members));
出力例は次のようになります。
C#Alice, Bob, Charlie
Alice, Bob, Charlie
team1とteam2は別のTeamですが、MembersのList<string>は同じインスタンスを参照しています。
そのため、team2.Membersを変更すると、team1.Membersにも影響します。
このようなケースでは、コレクションもコピーして渡す必要があります。
C#var team2 = team1 with
{
Name = "B Team",
Members = team1.Members.ToList()
};
または、List<T>ではなく、読み取り専用コレクションやイミュータブルコレクションを使う設計も検討します。
5. C# record with式の具体例
5-1. ユーザー情報を一部だけ変更する例
ユーザー情報の一部を変更する例です。
C#public record User(
int Id,
string Name,
string Email,
bool IsActive
);
var user = new User(
Id: 1,
Name: "Yamada",
Email: "yamada@example.com",
IsActive: true
);
var updatedUser = user with
{
Email = "yamada.new@example.com"
};
updatedUserは、メールアドレスだけが変更された新しいユーザー情報です。
C#Console.WriteLine(user.Email);
Console.WriteLine(updatedUser.Email);
出力例は次のようになります。
C#yamada@example.com
yamada.new@example.com
元のuserは変更されません。
実務では、画面から入力された変更内容をもとにDTOを更新する場合などに使えます。
5-2. 設定オブジェクトをコピー更新する例
アプリケーション設定の一部だけを変えたい場合にも、with式は便利です。
C#public record AppSettings(
string Environment,
int TimeoutSeconds,
bool EnableLogging
);
var defaultSettings = new AppSettings(
Environment: "Production",
TimeoutSeconds: 30,
EnableLogging: false
);
var debugSettings = defaultSettings with
{
Environment = "Development",
EnableLogging = true
};
TimeoutSecondsは元の値のまま、EnvironmentとEnableLoggingだけを変更できます。
C#Console.WriteLine(debugSettings);
出力例は次のようになります。
C#AppSettings { Environment = Development, TimeoutSeconds = 30, EnableLogging = True }
設定値のように、基本設定をもとに環境ごとの設定を作る場面では、with式によるコピー更新が読みやすくなります。
5-3. DTOやAPIレスポンスで使う例
DTOやAPIレスポンスを扱うときにもrecordとwith式はよく合います。
C#public record UserResponse(
int Id,
string Name,
string Email,
string Role
);
たとえば、内部用のレスポンスから、外部公開用に一部の値を変更する場合を考えます。
C#var internalResponse = new UserResponse(
Id: 1,
Name: "Sato",
Email: "sato@example.com",
Role: "Admin"
);
var publicResponse = internalResponse with
{
Email = "hidden@example.com",
Role = "User"
};
このように、元のDTOを壊さずに、用途に応じた別のDTOを作れます。
ただし、機密情報を隠す目的で使う場合は、そもそも公開用DTOを別に定義した方が安全なケースもあります。with式は便利ですが、セキュリティ設計そのものを置き換えるものではありません。
5-4. ネストしたrecordを更新する例
ネストしたrecordを更新する場合は、内側のrecordにもwith式を使います。
C#public record Address(
string PostalCode,
string City
);
public record User(
string Name,
Address Address
);
ユーザーの住所のうち、Cityだけを変更したい場合は次のように書きます。
C#var user1 = new User(
Name: "Alice",
Address: new Address("100-0001", "Tokyo")
);
var user2 = user1 with
{
Address = user1.Address with
{
City = "Osaka"
}
};
外側のUserをコピーし、さらに内側のAddressもコピー更新しています。
C#Console.WriteLine(user1.Address.City);
Console.WriteLine(user2.Address.City);
出力例は次のようになります。
C#Tokyo
Osaka
ネストした値を安全に更新したい場合は、このように階層ごとにwith式を使うのが基本です。
5-5. コレクションを含むrecordでの注意例
コレクションを含むrecordでは、浅いコピーに注意が必要です。
C#public record Cart(
int UserId,
List<string> Items
);
var cart1 = new Cart(
UserId: 1,
Items: new List<string> { "Book", "Pen" }
);
var cart2 = cart1 with
{
UserId = 2
};
cart2.Items.Add("Notebook");
Console.WriteLine(string.Join(", ", cart1.Items));
出力例は次のようになります。
C#Book, Pen, Notebook
cart2.Itemsを変更したのに、cart1.Itemsにも影響しています。これは、ItemsのList<string>が同じ参照だからです。
安全に扱うなら、コレクションもコピーします。
C#var cart2 = cart1 with
{
UserId = 2,
Items = cart1.Items.ToList()
};
さらに堅牢にしたい場合は、可変なList<T>を外部に公開せず、読み取り専用コレクションやイミュータブルコレクションの利用を検討します。
C#public record Cart(
int UserId,
IReadOnlyList<string> Items
);
ただし、IReadOnlyList<T>にしても、内部の実体がList<T>である場合は完全に不変になるわけではありません。設計上、本当に変更不可にしたい場合は、より厳密な方法を選ぶ必要があります。
6. recordのwith式とclassの違い
6-1. 通常のclassではwith式をそのまま使えない
通常のclassでは、recordのようにwith式をそのまま使うことはできません。
C#public class User
{
public string Name { get; init; }
public int Age { get; init; }
}
var user1 = new User
{
Name = "Alice",
Age = 30
};
var user2 = user1 with
{
Age = 31
};
このようなコードは、通常のclassではコンパイルエラーになります。
with式を使いたい場合は、record classとして定義します。
C#public record User
{
public string Name { get; init; }
public int Age { get; init; }
}
または、プライマリコンストラクタ形式で次のように書けます。
C#public record User(string Name, int Age);
recordはコピー更新に必要な仕組みをコンパイラが用意してくれるため、with式と自然に組み合わせられます。
6-2. recordはコピー更新のための仕組みが自動生成される
recordでは、コピー更新に必要な仕組みが自動生成されます。
C#public record User(string Name, int Age);
この短い定義だけで、次のようなコードが使えます。
C#var user1 = new User("Alice", 30);
var user2 = user1 with { Age = 31 };
通常のclassで同じようなことをしようとすると、自分でコピー用のメソッドやコンストラクタを実装する必要があります。
recordは、値の比較、見やすいToString()、コピー更新など、データ中心の型に必要な機能を自動で持たせられる点が特徴です。
6-3. classで同じことをする場合の実装例
通常のclassでwith式のようなコピー更新をしたい場合は、自分でメソッドを用意します。
C#public class User
{
public string Name { get; }
public int Age { get; }
public User(string name, int age)
{
Name = name;
Age = age;
}
public User With(
string? name = null,
int? age = null
)
{
return new User(
name ?? this.Name,
age ?? this.Age
);
}
}
使う側は次のようになります。
C#var user1 = new User("Alice", 30);
var user2 = user1.With(age: 31);
このようにすれば、通常のclassでも似たことはできます。
ただし、プロパティが増えるほど実装が面倒になります。また、nullを有効な値として扱いたい場合には、この例のようなnull合体演算子では正しく表現できないこともあります。
より厳密に実装するなら、変更する値を明示的に表す仕組みを用意する必要があります。
その点、recordとwith式を使えば、コピー更新を言語機能として簡潔に書けます。
6-4. recordとclassの使い分け
recordとclassは、どちらが常に優れているというものではありません。目的によって使い分けます。
recordが向いているのは、値そのものを表したい場合です。
たとえば、次のようなものです。
C#public record Money(decimal Amount, string Currency);
public record UserDto(int Id, string Name, string Email);
public record OrderCreatedEvent(int OrderId, DateTime CreatedAt);
これらは、データの中身が重要です。値が同じなら同じものとして扱いたい場面が多いため、recordと相性がよいです。
一方、通常のclassが向いているのは、状態や振る舞いを持つオブジェクトを表したい場合です。
C#public class BankAccount
{
public decimal Balance { get; private set; }
public void Deposit(decimal amount)
{
Balance += amount;
}
public void Withdraw(decimal amount)
{
Balance -= amount;
}
}
このような型では、単なる値のまとまりではなく、振る舞いやライフサイクルが重要になります。その場合は、通常のclassの方が自然です。
6-5. 値の比較と参照の比較の違い
recordとclassの大きな違いのひとつが、等価比較です。
通常のclassでは、特別な実装をしない限り、同じ値を持っていても別インスタンスなら等しくありません。
C#public class UserClass
{
public string Name { get; init; }
public int Age { get; init; }
}
var c1 = new UserClass { Name = "Alice", Age = 30 };
var c2 = new UserClass { Name = "Alice", Age = 30 };
Console.WriteLine(c1 == c2);
出力は通常、次のようになります。
C#False
一方、recordでは値が同じなら等しいと判断されます。
C#public record UserRecord(string Name, int Age);
var r1 = new UserRecord("Alice", 30);
var r2 = new UserRecord("Alice", 30);
Console.WriteLine(r1 == r2);
出力は次のようになります。
C#True
ただし、参照が同じかどうかを確認する場合はReferenceEqualsを使います。
C#Console.WriteLine(ReferenceEquals(r1, r2));
出力は次のようになります。
C#False
recordでは、値としては等しくても、インスタンスとしては別物というケースがあります。with式を使うときも、この違いを理解しておくことが大切です。
7. with式を使うメリット
7-1. 元のデータを壊さず安全に更新できる
with式の最大のメリットは、元のデータを壊さずに更新できることです。
C#var original = new User("Alice", 30);
var updated = original with
{
Age = 31
};
このとき、originalは変更されません。
C#Console.WriteLine(original.Age); // 30
Console.WriteLine(updated.Age); // 31
複数の処理で同じデータを参照している場合、元のデータが勝手に変更されるとバグの原因になります。
with式を使って新しい値を作る設計にすると、変更前と変更後を明確に分けられます。
7-2. コードが短く読みやすくなる
with式を使うと、コピー更新のコードが短くなります。
たとえば、次のようなrecordがあります。
C#public record Product(
int Id,
string Name,
int Price,
string Category,
bool IsActive
);
Priceだけを変更したい場合、コンストラクタで作り直すと次のようになります。
C#var updated = new Product(
product.Id,
product.Name,
1200,
product.Category,
product.IsActive
);
with式を使うと、変更箇所だけを書けます。
C#var updated = product with
{
Price = 1200
};
どの値を変更したのかが一目でわかるため、コードレビューもしやすくなります。
7-3. 状態管理や関数型スタイルと相性がよい
with式は、状態を直接変更せず、新しい状態を返すスタイルと相性がよいです。
C#public record CounterState(int Count);
CounterState Increment(CounterState state)
{
return state with
{
Count = state.Count + 1
};
}
この関数は、受け取ったstateを直接変更しません。新しいCounterStateを返します。
C#var state1 = new CounterState(0);
var state2 = Increment(state1);
Console.WriteLine(state1.Count); // 0
Console.WriteLine(state2.Count); // 1
このような書き方は、状態の変化を追いやすく、予期しない副作用を減らしやすいという利点があります。
7-4. テストしやすいコードを書きやすい
with式は、テストデータの作成にも便利です。
たとえば、基本となるテストユーザーを用意します。
C#var baseUser = new User(
Id: 1,
Name: "Test User",
Email: "test@example.com",
IsActive: true
);
テストケースごとに一部だけ変更できます。
C#var inactiveUser = baseUser with
{
IsActive = false
};
var anotherEmailUser = baseUser with
{
Email = "another@example.com"
};
毎回すべてのプロパティを指定する必要がないため、テストコードが読みやすくなります。
また、変更箇所が明確になるため、「このテストでは何を変えているのか」が伝わりやすくなります。
7-5. バグの原因になる副作用を減らせる
オブジェクトを直接変更する設計では、どこで値が変わったのか追いにくくなることがあります。
C#user.Age = 31;
このような代入がいろいろな場所にあると、状態の変化を追跡するのが難しくなります。
一方、with式を使うと、変更前と変更後を別の値として扱えます。
C#var updatedUser = user with
{
Age = 31
};
この書き方では、元のuserは変わりません。新しい値としてupdatedUserが作られます。
そのため、意図しない副作用を減らし、処理の流れを明確にできます。
8. with式を使うときの注意点
8-1. 浅いコピーであることを理解する
with式は便利ですが、ディープコピーではありません。
C#public record Profile(string Name, List<string> Tags);
var profile1 = new Profile(
"Alice",
new List<string> { "CSharp", "Backend" }
);
var profile2 = profile1 with
{
Name = "Bob"
};
この場合、profile1とprofile2のTagsは同じList<string>を参照しています。
C#profile2.Tags.Add("API");
Console.WriteLine(string.Join(", ", profile1.Tags));
出力例は次のようになります。
C#CSharp, Backend, API
profile2側で追加した値が、profile1側にも見えています。
with式はトップレベルのオブジェクトをコピーしますが、内部にある参照型オブジェクトの中身まで自動的に複製するわけではありません。
8-2. 参照型プロパティの変更は元オブジェクトにも影響する場合がある
参照型プロパティが可変である場合、コピー後のオブジェクトから参照先を変更すると、元オブジェクトにも影響する場合があります。
C#public record Document(
string Title,
StringBuilder Content
);
StringBuilderは可変オブジェクトです。
C#var document1 = new Document(
"Memo",
new StringBuilder("Hello")
);
var document2 = document1 with
{
Title = "New Memo"
};
document2.Content.Append(" World");
Console.WriteLine(document1.Content.ToString());
出力例は次のようになります。
C#Hello World
document2.Contentを変更したつもりでも、document1.Contentにも影響しています。
このような場合は、参照型プロパティも明示的にコピーする必要があります。
C#var document2 = document1 with
{
Title = "New Memo",
Content = new StringBuilder(document1.Content.ToString())
};
または、StringBuilderのような可変型ではなく、stringのような不変型を使えるか検討します。
8-3. initのみのプロパティと更新タイミング
initプロパティは、通常の代入では作成後に変更できません。
C#public record User
{
public string Name { get; init; }
}
次のコードはコンパイルエラーになります。
C#var user = new User { Name = "Alice" };
user.Name = "Bob"; // エラー
しかし、with式では指定できます。
C#var updated = user with
{
Name = "Bob"
};
これは、with式が新しいオブジェクトを作るときの初期化処理として扱われるためです。
ただし、すべてのプロパティがwith式で更新できるわけではありません。更新したいプロパティには、initまたはsetが必要です。
C#public record User
{
public string Name { get; }
public User(string name)
{
Name = name;
}
}
このようにgetのみで、initもsetもないプロパティは、with式で変更できません。
C#var user2 = user1 with
{
Name = "Bob" // エラー
};
with式で更新したい値は、初期化可能なプロパティとして設計しておく必要があります。
8-4. 可変コレクションを持たせる場合のリスク
recordにList<T>やDictionary<TKey, TValue>などの可変コレクションを持たせる場合は注意が必要です。
C#public record UserGroup(
string Name,
List<string> Users
);
このようなrecordは、一見イミュータブルに見えても、Usersの中身は変更できます。
C#var group = new UserGroup(
"Admins",
new List<string> { "Alice" }
);
group.Users.Add("Bob");
recordそのもののプロパティを差し替えていなくても、内部のリストは変更できてしまいます。
より安全にするには、次のような設計を検討します。
C#public record UserGroup(
string Name,
IReadOnlyList<string> Users
);
ただし、IReadOnlyList<T>は読み取り専用のインターフェースであり、元の実体が可変であれば完全な不変性を保証するわけではありません。
より厳密に不変性を保ちたい場合は、コピーして保持する、外部に可変コレクションを公開しない、イミュータブルコレクションを使うなどの対策が必要です。
8-5. パフォーマンス面で気をつけるケース
with式はコピーを作る構文です。そのため、大量のオブジェクトを頻繁にコピーする場合は、パフォーマンスやメモリ使用量に注意が必要です。
たとえば、ループ内で大きなデータを何度もコピーすると、不要なオブジェクト生成が増える可能性があります。
C#foreach (var item in items)
{
state = state with
{
Count = state.Count + 1
};
}
このようなコードが必ず問題になるわけではありません。しかし、高頻度で実行される処理や大量データを扱う処理では、コピーコストを意識する必要があります。
また、recordが大きな参照型オブジェクトを持っている場合、with式自体は浅いコピーなので中身までは複製しません。ただし、別途コレクションなどを明示的にコピーする場合は、その分のコストが発生します。
実務では、まず読みやすさと安全性を優先し、パフォーマンスが問題になった箇所だけ計測して最適化するのが現実的です。
9. C# record with式でよくあるエラーと対処法
9-1. classでwith式を使おうとしてエラーになる
よくあるのが、通常のclassでwith式を使おうとしてエラーになるケースです。
C#public class User
{
public string Name { get; init; }
public int Age { get; init; }
}
var user1 = new User
{
Name = "Alice",
Age = 30
};
var user2 = user1 with
{
Age = 31
};
通常のclassでは、このようなwith式は使えません。
対処法は、型をrecordにすることです。
C#public record User
{
public string Name { get; init; }
public int Age { get; init; }
}
または、次のように簡潔に書けます。
C#public record User(string Name, int Age);
通常のclassで同じような機能が必要な場合は、コピー用メソッドを自分で実装します。
9-2. setできないプロパティを更新しようとしてエラーになる
with式で指定するプロパティは、初期化または設定が可能である必要があります。
次のようにgetのみのプロパティは、with式で更新できません。
C#public record User
{
public string Name { get; }
public User(string name)
{
Name = name;
}
}
この場合、次のコードはエラーになります。
C#var user2 = user1 with
{
Name = "Bob"
};
対処法は、initアクセサを用意することです。
C#public record User
{
public string Name { get; init; }
}
または、プライマリコンストラクタで定義します。
C#public record User(string Name);
with式で更新する予定のプロパティは、initまたはsetを使って設計します。
9-3. ネストした値が期待通りにコピーされない
ネストしたrecordで、内側の値が期待どおりにコピーされないケースもあります。
C#public record Address(string City);
public record User(string Name, Address Address);
var user1 = new User("Alice", new Address("Tokyo"));
var user2 = user1 with
{
Name = "Bob"
};
この場合、Addressは同じ参照です。
C#Console.WriteLine(ReferenceEquals(user1.Address, user2.Address));
出力は次のようになります。
C#True
Addressも別インスタンスにしたい場合は、内側もwith式でコピーします。
C#var user2 = user1 with
{
Name = "Bob",
Address = user1.Address with { }
};
さらにCityを変更したい場合は、次のように書きます。
C#var user2 = user1 with
{
Address = user1.Address with
{
City = "Osaka"
}
};
with式は自動で深い階層までコピーしてくれるわけではないため、必要な階層ごとにコピー更新する必要があります。
9-4. record structとrecord classで挙動を混同する
record classは参照型、record structは値型です。
C#public record class UserClassRecord(string Name);
public record struct UserStructRecord(string Name);
record classは参照型なので、変数に代入すると参照がコピーされます。
C#var a = new UserClassRecord("Alice");
var b = a;
Console.WriteLine(ReferenceEquals(a, b)); // True
一方、record structは値型なので、代入時に値がコピーされます。
C#var x = new UserStructRecord("Alice");
var y = x;
record structではReferenceEqualsのような参照比較の考え方は基本的に合いません。
ただし、with式を使う場合の見た目は似ています。
C#var b = a with { Name = "Bob" };
var y = x with { Name = "Bob" };
どちらも「一部を変更したコピーを作る」点は同じですが、型が参照型なのか値型なのかによって、代入やメモリ上の扱いが異なります。
9-5. nullを含むプロパティ更新時の注意
with式では、プロパティにnullを設定することもできます。
C#public record User(
string Name,
string? Email
);
var user1 = new User("Alice", "alice@example.com");
var user2 = user1 with
{
Email = null
};
このコード自体は、Emailがnullableとして定義されていれば問題ありません。
一方、nullableではないプロパティにnullを入れようとすると、警告やエラーの原因になります。
C#public record User(
string Name,
string Email
);
var user2 = user1 with
{
Email = null
};
Nullable Reference Typesを有効にしている場合、stringは非nullableとして扱われるため、null代入に警告が出ます。
対処法は、nullを許可するならstring?にすることです。
C#public record User(
string Name,
string? Email
);
また、nullを許可しない設計なら、with式でnullを設定しないようにバリデーションや型設計を整えます。
10. record with式の実務での使いどころ
10-1. DTOの一部更新
DTOの一部だけを変更したい場合、recordとwith式は便利です。
C#public record UserDto(
int Id,
string Name,
string Email,
bool IsActive
);
var dto = new UserDto(1, "Alice", "alice@example.com", true);
var updatedDto = dto with
{
IsActive = false
};
APIの入力値をもとに一部だけ変更したDTOを作る場合や、レスポンス用に値を調整する場合に使えます。
ただし、データの意味が大きく変わる場合は、別のDTO型を用意した方がわかりやすいこともあります。
10-2. 設定値やオプションの変更
基本設定をもとに、一部だけ異なる設定を作りたい場合にもwith式は有効です。
C#public record RetryOptions(
int MaxRetryCount,
int DelayMilliseconds,
bool UseExponentialBackoff
);
var defaultOptions = new RetryOptions(
MaxRetryCount: 3,
DelayMilliseconds: 1000,
UseExponentialBackoff: true
);
var testOptions = defaultOptions with
{
MaxRetryCount = 1,
DelayMilliseconds = 10
};
テスト環境、開発環境、本番環境で少しだけ設定を変えたい場合に読みやすいコードになります。
10-3. 状態管理での新しい状態の生成
状態管理では、現在の状態を直接変更せず、新しい状態を作る設計がよく使われます。
C#public record TodoState(
IReadOnlyList<string> Items,
string Filter
);
フィルターだけを変更する場合は次のように書けます。
C#var newState = currentState with
{
Filter = "Completed"
};
項目を追加する場合は、コレクションも新しく作ると安全です。
C#var newState = currentState with
{
Items = currentState.Items
.Append("New Task")
.ToList()
};
元の状態を残したまま、新しい状態を明確に作れるため、状態遷移を追いやすくなります。
10-4. テストデータの作成
テストコードでは、似たようなデータを少しずつ変えて使うことがよくあります。
C#public record Order(
int Id,
string Status,
decimal Amount
);
var baseOrder = new Order(
Id: 1,
Status: "Created",
Amount: 1000m
);
成功ケース、失敗ケース、キャンセルケースなどを簡単に作れます。
C#var paidOrder = baseOrder with
{
Status = "Paid"
};
var cancelledOrder = baseOrder with
{
Status = "Cancelled"
};
var expensiveOrder = baseOrder with
{
Amount = 100000m
};
テストごとに変更点だけが見えるため、テストの意図がわかりやすくなります。
10-5. イベントやコマンドオブジェクトの生成
イベントやコマンドのように、データを持ち運ぶためのオブジェクトにもrecordは向いています。
C#public record CreateUserCommand(
string Name,
string Email,
string Role
);
基本となるコマンドをもとに、一部だけ変えた別のコマンドを作れます。
C#var command = new CreateUserCommand(
Name: "Alice",
Email: "alice@example.com",
Role: "User"
);
var adminCommand = command with
{
Role = "Admin"
};
イベントでも同様です。
C#public record UserRegisteredEvent(
int UserId,
string Email,
DateTime OccurredAt
);
recordを使うと、イベントやコマンドを値として扱いやすくなり、ログ出力や比較、テストもしやすくなります。
11. record with式を使うべきケース・使わない方がよいケース
11-1. 使うべきケース
recordとwith式を使うべきケースは、データを値として扱いたい場合です。
たとえば、DTO、設定値、コマンド、イベント、状態オブジェクト、テストデータなどです。
C#public record SearchCondition(
string Keyword,
int Page,
int PageSize
);
var condition = new SearchCondition("c#", 1, 20);
var nextPage = condition with
{
Page = 2
};
このようなデータは、元の値を直接書き換えるよりも、新しい値として作り直す方が安全で読みやすくなります。
また、値の比較をしたい場合にもrecordは便利です。
C#var a = new SearchCondition("c#", 1, 20);
var b = new SearchCondition("c#", 1, 20);
Console.WriteLine(a == b); // True
データの中身が同じなら同じものとして扱いたい場合、recordは有力な選択肢です。
11-2. 通常のclassの方が向いているケース
通常のclassの方が向いているのは、オブジェクトの状態や振る舞いが重要なケースです。
たとえば、銀行口座、ショッピングカート、ドメインエンティティ、接続管理、ファイル操作などです。
C#public class BankAccount
{
public decimal Balance { get; private set; }
public void Deposit(decimal amount)
{
Balance += amount;
}
public void Withdraw(decimal amount)
{
Balance -= amount;
}
}
このような型では、値のまとまりとしてコピーするよりも、オブジェクト自身が状態を管理し、メソッドを通じて変更する方が自然です。
また、同じ値を持っていても別の存在として扱いたい場合は、recordの値比較がかえって不自然になることがあります。
たとえば、同じ名前、同じ残高の銀行口座が2つあっても、それらは別の口座です。このような場合は通常のclassの方が向いています。
11-3. 完全なディープコピーが必要なケース
完全なディープコピーが必要な場合、with式だけでは不十分です。
C#public record User(
string Name,
Address Address
);
public record Address(
string City,
List<string> Lines
);
このようなネストした構造で、すべての階層を別インスタンスとしてコピーしたい場合は、各階層を明示的にコピーする必要があります。
C#var user2 = user1 with
{
Address = user1.Address with
{
Lines = user1.Address.Lines.ToList()
}
};
階層が深い場合や、複雑なオブジェクトグラフを持つ場合は、with式だけに頼ると不完全なコピーになりやすいです。
そのようなケースでは、専用のコピー処理、マッピング処理、シリアライズを使った複製、ドメインに応じたファクトリメソッドなどを検討します。
11-4. 可変オブジェクト中心の設計では注意が必要
アプリケーション全体が可変オブジェクト中心で設計されている場合、recordとwith式を部分的に導入すると、かえって混乱することがあります。
C#public record User(
string Name,
List<string> Roles
);
このようなrecordは、見た目はイミュータブルに近く見えますが、Rolesの中身は変更できます。
C#user.Roles.Add("Admin");
この場合、recordを使っていても副作用を完全には防げません。
recordとwith式を使うなら、内部に持つ値もできるだけ不変に近づける設計が重要です。
可変コレクションを多用する設計では、通常のclassで明示的に状態変更を管理した方がわかりやすいこともあります。
まとめ
C#のrecordにおけるwith式は、元の値を残したまま、一部だけ変更したコピーを作るための構文です。
C#var updated = original with
{
Property = newValue
};
recordとwith式を使うことで、DTO、設定値、状態オブジェクト、イベント、コマンド、テストデータなどを簡潔かつ安全に扱いやすくなります。
特に重要なポイントは、with式が元のインスタンスを直接変更しないことです。
C#var user1 = new User("Alice", 30);
var user2 = user1 with { Age = 31 };
この場合、user1は変更されず、user2として新しい値が作られます。
一方で、with式はディープコピーではなく浅いコピーです。List<T>やStringBuilderなどの可変な参照型プロパティを持つ場合、コピー後のオブジェクトから参照先を変更すると、元のオブジェクトにも影響することがあります。
安全に使うためには、次の点を意識することが大切です。
recordは値として扱いたいデータに使うこと、with式はコピー更新として理解すること、参照型プロパティは浅いコピーになること、可変コレクションを持たせる場合は注意すること、通常のclassとrecordの役割を混同しないことです。
recordとwith式を正しく使えば、C#でイミュータブルに近いデータ表現を書きやすくなり、読みやすく安全なコードにつながります。

