ドメイン型を強化するRust newtype パターン

プリミティブを1要素タプル構造体でラップし、UserId と OrderId を型システムに区別させます。実行時コストゼロで強力な型付けが得られます。

Patterns

詳細な説明

serde と newtype パターン

newtype はちょうど1つのフィールドを持つタプル構造体です。最初は無意味に見える(UserId は中身が i64 でしかない)かもしれませんが、UserId を期待する関数に order_id を渡すコードをコンパイラが拒否してくれます。

起点となるJSON

{
  "user_id": 1234567,
  "order_id": 9876543
}

自動生成された出力

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
    pub user_id: i32,
    pub order_id: i32,
}

newtype に昇格

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct UserId(pub i64);

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct OrderId(pub i64);

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
    pub user_id: UserId,
    pub order_id: OrderId,
}

transparent シリアライズ

デフォルトでは serde はタプル構造体を1要素配列としてシリアライズするため、UserId(1)[1] になります。内側の値そのもの(1)として出力するには #[serde(transparent)] を付けます。

#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UserId(pub i64);

これで JSON 上は素の i64 と区別できなくなり、Rust 側だけが誤った混同を防いでくれます。

ドメインメソッドの追加

newtype にもメソッド、トレイト、コンストラクタを定義できます。

impl UserId {
    pub fn new(id: i64) -> Result<Self, &'static str> {
        if id <= 0 { return Err("id must be positive"); }
        Ok(UserId(id))
    }
}

これは "parse, don't validate"(検証ではなく解釈する)という設計指針の基盤です。UserId を持っているということ自体が「正しい値」を保証するからです。

ユースケース

ドメイン駆動設計、金融計算、複数の ID 型を扱うコードベースは newtype の恩恵を受けます。「間違った関数に間違った ID を渡す」種類のバグを丸ごとコンパイル時に潰せます。

試してみる — JSON to Rust Struct Converter

フルツールを開く