じゃあ、おうちで学べる

本能を呼び覚ますこのコードに、君は抗えるか

newtype と `PhantomData` で「混ぜたら型エラー」を作る

はじめに

UserIdOrderId を取り違えてバグを生み出す経験は、String で識別子を扱っていれば誰しもあります。Rust では newtype で別の型に分けるだけで、コンパイラに止めてもらえます。さらに PhantomData を組み合わせれば、「JPY と USD を足し算する」のような単位ミスも型エラーにできます。

ランタイムコストはゼロ。型情報はコンパイル後に消えます。

newtype の基本

newtype は「struct でラップして、別の型として扱う」だけのパターンです。

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UserId(String);

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct OrderId(String);

impl UserId {
    pub fn new(s: impl Into<String>) -> Self {
        Self(s.into())
    }
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl OrderId {
    pub fn new(s: impl Into<String>) -> Self {
        Self(s.into())
    }
}

fn lookup_user(id: &UserId) -> String {
    format!("looking up {}", id.as_str())
}

fn main() {
    let u = UserId::new("alice");
    let o = OrderId::new("o-1");

    println!("{}", lookup_user(&u));
    // lookup_user(&o); // ← compile error: 期待型は &UserId, 実際は &OrderId
    println!("{:?}", o);
}

UserId の内部表現は String で、OrderId の内部表現も String ですが、型システム上は別物です。lookup_user(&o) を書くと型エラーになります。

ergonomics を上げる

newtype はそのままだと使い勝手が悪い (every cast が手書きになる) ので、よく使う変換を From / AsRef / Display で実装します。

use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct UserId(String);

impl UserId {
    pub fn new(s: impl Into<String>) -> Self {
        Self(s.into())
    }
}

// `let id: UserId = "alice".into();` で書ける
impl From<&str> for UserId {
    fn from(s: &str) -> Self {
        Self(s.to_owned())
    }
}

// `id.as_ref()` で `&str` を取れる
impl AsRef<str> for UserId {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

// 表示は `Debug` と分けて意図的にカスタマイズ
impl fmt::Display for UserId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "user:{}", self.0)
    }
}

fn main() {
    let id: UserId = "alice".into();
    println!("{id}");      // user:alice
    println!("{id:?}");    // UserId("alice")
}

DisplayDebug を分けるのがポイント。Debug は開発者向けの構造表示、Display はユーザー向けの整形です。

derive_more で boilerplate を削る

From / AsRef / Display を毎回手書きしたくない場合、derive_more クレートが使えます。

[dependencies]
derive_more = { version = "2", features = ["as_ref", "display", "from"] }
use derive_more::{AsRef, Display, From};

#[derive(Debug, Clone, PartialEq, Eq, Hash, AsRef, Display, From)]
#[display("user:{_0}")]
pub struct UserId(String);

これで From<String>, AsRef<String>, Display が自動生成されます。#[display("...")] 属性で書式を指定。手書きより短く済みます。

「単位」を newtype で表す

newtype の真価は「数値に単位を付ける」場面で出ます。PhantomData<T> を使って、内部表現は同じ i64 だけど型レベルで通貨を分けるパターン。

use std::marker::PhantomData;
use std::ops::Add;

#[derive(Debug, Clone, Copy)]
pub struct Currency<C> {
    cents: i64,
    _tag: PhantomData<C>,
}

impl<C> Currency<C> {
    pub const fn new(cents: i64) -> Self {
        Self { cents, _tag: PhantomData }
    }
    pub const fn cents(self) -> i64 {
        self.cents
    }
}

impl<C> Add for Currency<C> {
    type Output = Self;
    fn add(self, rhs: Self) -> Self {
        Self::new(self.cents + rhs.cents)
    }
}

#[derive(Debug)]
pub struct Jpy;
#[derive(Debug)]
pub struct Usd;

fn main() {
    let a: Currency<Jpy> = Currency::new(1_000);
    let b: Currency<Jpy> = Currency::new(500);
    println!("JPY total = {}", (a + b).cents());

    let _u: Currency<Usd> = Currency::new(100);
    // a + _u; // ← compile error: 通貨が違う
}

Currency<Jpy>Currency<Usd> は別の型です。+ は同じ通貨同士でしか定義されていないので、混ぜようとするとコンパイルエラーになります。

ランタイムにおける Currency<C> の実体は単なる i64 です (PhantomData<C> のサイズが 0 だから)。型情報はコンパイラだけが知っていて、生成されたバイナリには残りません。安全性を上げてもパフォーマンスは犠牲にならない のが Rust の強みです。

いつ newtype を使うか

過剰になるケースもあります。判断の目安を表にしておきます。

使う 使わない
ID 系 (UserId, OrderId) 一時的な計算用の数値
単位がある値 (距離, 時間, 通貨) プレーンな整数の数え上げ
検証済みの文字列 (EmailAddress) 内部の中間表現
公開 API の引数・戻り値 関数内ローカル変数

「同じ String でも、文脈で意味が違う」場面で newtype が効きます。「同じ i64 を足し引きしているだけ」のローカル計算では、newtype を入れると逆に煩雑です。

まとめ

  • newtype = pub struct UserId(String); のラッパ
  • 取り違えバグを型エラーで止められる
  • From / AsRef / Display を実装して ergonomics を上げる
  • derive_more で boilerplate を削れる
  • PhantomData<T> で「単位」を型に乗せられる (通貨, 距離, 時間)
  • ランタイムコストはゼロ

公開 API の ID は最初から newtype にしておくと、後で「どこで StringString に渡してミスっているか」を探すデバッグが要らなくなります。

関連