はじめに
UserId と OrderId を取り違えてバグを生み出す経験は、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") }
Display と Debug を分けるのがポイント。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 にしておくと、後で「どこで String を String に渡してミスっているか」を探すデバッグが要らなくなります。
関連
std::marker::PhantomData: https://doc.rust-lang.org/std/marker/struct.PhantomData.htmlderive_moreクレート: https://docs.rs/derive_more/