ユーザー定義変換演算子による型マッピング
AutoMapperが解決しようとする問題
Web API やドメイン駆動設計では、複数の層をまたぐ型変換が頻繁に発生します。
Request DTO → Domain Model → Response DTO
この変換を手書きすると冗長になるため、AutoMapper などのライブラリが使われてきました。しかし AutoMapper にはいくつかの課題があります。
| 課題 | 内容 |
|---|---|
| ランタイムエラー | マッピング設定のミスはビルドではなく実行時に発覚する |
| リフレクション | 内部でリフレクションを使うためパフォーマンスが劣化する |
| 外部依存 | NuGetパッケージへの依存が増える |
| 設定の分散 | Profile クラスがコードと離れた場所に定義される |
C# にはコンパイル時に解決される型変換の仕組みが標準で備わっており、外部ライブラリなしでこれらの課題を解決できます。
ユーザー定義変換演算子とは
operator キーワードに implicit(暗黙的)または explicit(明示的)を組み合わせることで、型変換のロジックを型自身に定義できます。
public static implicit operator TargetType(SourceType source) { ... }
public static explicit operator TargetType(SourceType source) { ... }
制約: 変換元・変換先のいずれか一方が、演算子を定義するクラスまたは構造体でなければなりません。
implicit(暗黙的変換)
キャスト構文が不要で、代入や引数渡しで自動的に変換されます。
TargetType target = source; // キャスト不要
Method(source); // 引数でも自動変換
使用条件: 情報の損失がなく、例外を投げない変換のみ。
explicit(明示的変換)
呼び出し側でキャスト構文 (TargetType) が必要です。
TargetType target = (TargetType)source; // キャスト必須
使用条件: バリデーション、情報の損失、例外を投げる可能性がある変換。
「変換が必ず成功し、情報が失われない」→ implicit
「失敗する可能性がある、または呼び出し側に意識させたい」→ explicit
実装パターン
パターン 1: DTO ↔ Domain Model
// Request DTO(外部から来るデータ)
public record CreateUserRequest(string Name, string Email);
// Domain Model
public class User
{
public int Id { get; private set; }
public string Name { get; private set; }
public string Email { get; private set; }
private User() { }
// DTO → Domain: バリデーションがあるため explicit
public static explicit operator User(CreateUserRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name))
throw new ArgumentException("Name is required.");
if (!req.Email.Contains('@'))
throw new ArgumentException("Invalid email format.");
return new User { Name = req.Name, Email = req.Email };
}
}
// Response DTO
public record UserResponse(int Id, string Name, string Email);
// Domain → Response DTO: 常に成功するため implicit
// (Domain側ではなく DTO 側に定義する例)
public record UserResponse(int Id, string Name, string Email)
{
public static implicit operator UserResponse(User user)
=> new(user.Id, user.Name, user.Email);
}
// コントローラーでの使用
[HttpPost]
public IActionResult Create(CreateUserRequest req)
{
var user = (User)req; // explicit: 意図が明確
_repository.Add(user);
UserResponse response = user; // implicit: 安全な変換
return Ok(response);
}
パターン 2: Value Object
値オブジェクトへの変換はプリミティブ型との相互変換が必要になるケースが多く、変換演算子が特に効果的です。
public readonly record struct Email
{
public string Value { get; }
public Email(string value)
{
if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
throw new ArgumentException($"'{value}' is not a valid email address.");
Value = value;
}
// string → Email: バリデーションがあるため explicit
public static explicit operator Email(string value) => new(value);
// Email → string: 常に成功するため implicit
public static implicit operator string(Email email) => email.Value;
public override string ToString() => Value;
}
public readonly record struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount));
Amount = amount;
Currency = currency;
}
// decimal → Money: 通貨情報が足りないので定義しない(情報の損失)
// Money → decimal: 金額のみ取り出す場合は implicit も可
public static implicit operator decimal(Money money) => money.Amount;
}
// 使用例
var email = (Email)"user@example.com"; // explicit
string raw = email; // implicit (Email → string)
var price = new Money(1500m, "JPY");
decimal amount = price; // implicit (Money → decimal)
パターン 3: 外部型ラッパー
外部ライブラリの型やプリミティブ型をドメイン型に変換する場合にも有効です。
// Guid ラッパー(型安全な ID)
public readonly record struct UserId
{
public Guid Value { get; }
public UserId(Guid value) => Value = value;
public static UserId NewId() => new(Guid.NewGuid());
public static explicit operator UserId(Guid guid) => new(guid);
public static implicit operator Guid(UserId id) => id.Value;
public static implicit operator string(UserId id) => id.Value.ToString();
}
コレクション変換(LINQ との組み合わせ)
ユーザー定義変換演算子はコレクション全体には自動適用されません。Select と組み合わせて変換します。
List<User> users = _repository.GetAll();
// implicit 変換の場合
IEnumerable<UserResponse> responses = users.Select(u => (UserResponse)u);
// explicit 変換の場合
var dtos = requests.Select(r => (User)r).ToList();
// implicit の場合はキャスト不要なケースも
UserResponse single = users.First(); // implicit なら直接代入可能
AutoMapper との比較
| 観点 | AutoMapper | ユーザー定義変換演算子 |
|---|---|---|
| 型安全性 | ランタイム検証 | コンパイル時検証 |
| パフォーマンス | リフレクション(低速) | ネイティブ呼び出し(高速) |
| 設定の場所 | Profile クラスに分散 | 型自身に集約 |
| 外部依存 | NuGetパッケージ必要 | 不要(言語標準機能) |
| コレクション変換 | 自動 | LINQ .Select() 必要 |
| 複雑な条件分岐 | Converter / ValueResolver | メソッド内に直接実装 |
| 設定ミスの発覚 | 実行時 | ビルド時 |
| プロパティ名変更への追従 | 設定更新が必要(サイレント失敗の恐れ) | コンパイルエラーで即検知 |
AutoMapper が有利なケース: 大量のDTOを持ち、プロパティ名が一致するだけの単純な変換が多い場合は、AutoMapperの規約ベース自動マッピングが工数を削減します。
注意点・制限
is / as 演算子は変換演算子を考慮しない
var email = (Email)"user@example.com";
// NG: is/as はユーザー定義変換を無視する
if (email is string s) { } // string ではないため false
var str = email as string; // null が返る
// OK: キャスト式を使う
var str2 = (string)email; // implicit なのでキャストは省略可だが明示してもよい
継承関係には定義できない
// コンパイルエラー: 基底クラスや派生クラスへの変換演算子は定義不可
public static implicit operator Animal(Dog dog) { ... } // NG
これは C# の型システムが継承による変換を保証しているためです。継承関係の変換には変換演算子ではなくキャストを使います。
変換元・変換先の一方が定義クラスであること
// NG: int → string は両方とも自分の型ではない
public static implicit operator string(int value) { ... } // コンパイルエラー
checked 変換演算子(C# 11 以降)
数値オーバーフローなど、checked コンテキストで異なるふるまいが必要な場合は checked キーワードを追加できます。
public static explicit operator checked int(long value)
{
if (value > int.MaxValue) throw new OverflowException();
return (int)value;
}
まとめ
implicit operator: 安全・損失なし・例外なしの変換に使うexplicit operator: バリデーションや例外が発生しうる変換に使う- 型に変換ロジックを集約することで、設定ファイルの分散とランタイムエラーを排除できる
- コレクション変換は LINQ の
Selectと組み合わせる is/as演算子との非互換性に注意する