型で守るRustのバリデーション:シンプルなNewTypeから正規化、高度な合成パターンまで #ヌーラボブログリレー2025夏
この記事はヌーラボブログリレー2025夏のTechブログ9日目として投稿しています。
はじめまして。Scalaを書きにきたつもりが業務でもRustを書くことになってしまった新卒のゆーです。
今回は、型駆動開発を推進するためのアイデアの一つを紹介したいと思います。
NewType Pattern(Idiom)について
具体的な型をそのまま扱うのではなく、別の型として扱いたいときによく使われるパターンとして、NewType Patternがあります。具体的な型をフィールドを公開せずに包みます。
struct UserId(u32);
// または
struct UserId {
inner: u32
}
これは、以下のような特徴があります。
-
オーバーヘッドがない
Rustでは明示的にBoxなどのスマートポインタに包まなければボクシングは行われないため、オーバーヘッドなしに扱うことができます。もちろん、サイズは内部の型と同じです。
-
型に意味を与えることができる
u32では、負値を取り得ない32bitの整数(0~4294967295)であるということしかわかりません。UserIdという名前をつけることにより、これはUserのidentifier(識別子)であるという意味を持たせることができます。
-
意味の違う型と区別することができる
UserIdとu32は別の型なので、コンパイラに意味論の検証を押し付けることができます。
fn save_user(id: UserId) -> Result<(), Error> { unimplemented!() } fn main() { let _ = save_user(0u32); // Compile Error! // ... }
-
内部実装を隠蔽し、差し替え可能にする
OOPにおける全てがprivateなクラスと同じことですが、フィールドを公開しないので内部構造を差し替え可能にすることができます。
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] struct UserId(u32); // struct UserId(uuid::Uuid);にしても動作する(作成方法やシリアライズの部分はもちろん考慮が必要ですが)
-
バリデーション漏れと多重バリデーションの防止
データが意味論的に有効でない時にその型の値を生成できないようにします。
/// 空ではない文字列 struct NonEmptyString(String); impl TryFrom<String> for NonEmptyString { type Error = EmptyError; fn try_from(value: String) -> Result<Self, Self::Error> { if value.is_empty() { Err(EmptyError) } else { Ok(Self(value)) } } } fn example(s: String) -> anyhow::Result<()> { let nonempty: NonEmptyString = s.try_into()?; // 以後は空ではないことが静的に保証されるため多重チェックを防止できる }
4つのNewType Pattern
NewType Patternの使用方法は大きく四つに分ける(排他ではない)ことができると考えています。3と4をNewType Patternと呼ぶのが正しいのかは分かりませんが、ここではNewType Patternの一種であるということにします。
-
1. 基本的な型(≠プリミティブ型)の制約を強める型
- NonEmptyList
- NonEmptyString
- AsciiString
-
2. ドメイン型
- UserId
- OrderError
これは、標準ライブラリでも使用されています。
#[stable(feature = "thread_id", since = "1.19.0")] #[derive(Eq, PartialEq, Clone, Copy, Hash, Debug)] pub struct ThreadId(NonZero<u64>);
-
3.
Arc<Mutex<T>>
など、型を内包する型をひとまとまりに扱いたい(メソッドを実装したい)#[derive(Clone)] struct JwtConfig(Arc<JwtConfigInner>);
-
4. 外部の型に外部のtraitを実装したい
Orphan Rulesを回避するためのパターンです。
impl axum::extract::FromRef<AppState> for JwtConfig { fn from_ref(input: &AppState) -> Self { input.jwt_config.clone() } }
他のパターンにも思い至ったのですが、忘れてしまいました。思い出し次第追記します。
それはさておき、今回は1について説明します。とはいえ、今回の手法は他にも適用可能なものが多いです。
NonEmptyStringの実装例
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(try_from = "String")]
pub struct NonEmptyString(String);
#[derive(Debug, thiserror::Error)]
#[error("string cannot be empty")]
pub struct EmptyError;
impl TryFrom<String> for NonEmptyString {
type Error = EmptyError;
fn try_from(value: String) -> Result<Self, Self::Error> {
if value.is_empty() {
Err(EmptyError)
} else {
Ok(Self(value))
}
}
}
#[serde(try_from = "String")]
をつけることで、TryFrom<String>
を介してDeserializeさせるようにすることができます。
Deref trait
Deref
を実装することで、NonEmptyString
に対してString
の&self
を取るメソッドを自動的に使用できるようになります。
impl std::ops::Deref for NonEmptyString {
type Target = String; // スライス型であるstrでも良いが、そうした場合str::as_strはunstableなので手動実装する必要がある
fn deref(&self) -> &Self::Target {
&self.0
}
}
let non_empty = NonEmptyString::try_from("Hello").unwrap();
// これらのメソッドはDerefによって自動的に利用可能
assert_eq!(non_empty.len(), 5);
assert!(non_empty.starts_with('H'));
assert_eq!(non_empty.to_uppercase(), "HELLO");
DerefMut
は実装しません。DerefMut
を実装すると、内部のString
への可変参照が取得でき、制約を破壊される可能性があります。
安全なメソッドの手動実装
制約を破壊しない操作のみを選択的に実装します。
impl NonEmptyString {
pub fn push_str(&mut self, string: &str) {
self.0.push_str(string);
}
pub fn push(&mut self, ch: char) {
self.0.push(ch);
}
pub fn insert_str(&mut self, idx: usize, string: &str) {
self.0.insert_str(idx, string);
}
}
このように、制約を保持するメソッドのみを選択的に実装することで、型安全性を保ちながら利便性を両立できます。
const genericsを使用した最小長制約
NonEmptyだけでなく、「最低N文字」という制約を課したい場合があるとします。このような場合、const genericsを使用することで柔軟な制約を表現できます。
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct NString<const MIN_LEN: usize>(String);
#[derive(Debug, thiserror::Error)]
#[error("Not enough elements: expected {expected}, but got {actual}")]
pub struct NotEnoughElementsError {
pub expected: usize,
pub actual: usize,
}
impl<const MIN_LEN: usize> TryFrom<String> for NString<MIN_LEN> {
type Error = NotEnoughElementsError;
fn try_from(value: String) -> Result<Self, Self::Error> {
let len = value.chars().count();
if len < MIN_LEN {
Err(NotEnoughElementsError {
expected: MIN_LEN,
actual: len,
})
} else {
Ok(Self(value))
}
}
}
impl<const MIN_LEN: usize> std::ops::Deref for NString<MIN_LEN> {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
// 便利な型エイリアス
pub type NonEmptyString = NString<1>;
pub type MinThreeCharString = NString<3>;
シーケンス型での活用例
文字列ではあまり必要性を感じないかもしれませんが、Vec
など他のシーケンス型では有用かもしれません。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NVec<T, const MIN_LEN: usize>(Vec<T>);
さらなるバリデーションの抽象化
前節では、const genericsを使用した柔軟な制約を見てきました。しかし、さらに複雑なバリデーションが必要な場合や、複数のバリデーションを組み合わせたい場合はどうでしょうか。そこで、より抽象的なバリデーションシステムを構築します。
Validatorトレイトによる統一的なインターフェース
バリデーションロジックを統一的に扱うために、まずはValidator
トレイトを定義します。最初はシンプルに、検証のみを行うインターフェースから始めましょう。
pub trait Validator<T> {
type Error: std::fmt::Display;
fn name() -> Cow<'static, str>;
fn description() -> Cow<'static, str>;
fn validate(value: &T) -> Result<(), Self::Error>;
}
このトレイトは3つのメソッドを定義しています。
-
name()
: バリデータの名前を返します。型名として使用されます。 -
description()
: バリデーションルールの説明を返します。 -
validate()
: 実際のバリデーション処理を行います。値の検証のみで、変換は行いません。
name()
とdescription()
はutoipa(OpenAPI仕様生成ライブラリ)でのAPI文書生成に使用するために定義しています。
文字列バリデータの実装例
Validator
トレイトを使用して、基本的な文字列バリデータを実装してみましょう。
NonEmptyバリデータ
まずは、空文字列をチェックするシンプルなバリデータです。
pub enum NonEmpty {} // 値として存在できる必要のない型なので、バリアントのないenumとして定義する
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
#[error("Expected at least 1 character, but empty string provided")]
pub struct EmptyError;
impl Validator<String> for NonEmpty {
type Error = EmptyError;
fn name() -> Cow<'static, str> {
Cow::Borrowed("NonEmpty")
}
fn description() -> Cow<'static, str> {
Cow::Borrowed("String must not be empty")
}
fn validate(value: &String) -> Result<(), Self::Error> {
if value.is_empty() {
Err(EmptyError)
} else {
Ok(())
}
}
}
この実装では、validate
メソッドが文字列の参照を受け取り、空文字列の場合にエラーを返します。
MaxLengthバリデータ
次に、const genericsを使用した文字数制限バリデータを実装します。
pub enum MaxLength<const N: usize> {}
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
#[error(
"String exceeds maximum length of {max_length} characters: actual length is {actual_length}"
)]
pub struct MaxLengthError {
pub max_length: usize,
pub actual_length: usize,
}
impl<const N: usize> Validator<String> for MaxLength<N> {
type Error = MaxLengthError;
fn name() -> Cow<'static, str> {
Cow::Owned(format!("MaxLength{N}"))
}
fn description() -> Cow<'static, str> {
Cow::Owned(format!("String must not exceed {N} characters"))
}
fn validate(value: &String) -> Result<(), Self::Error> {
let count = value.chars().count();
if count > N {
Err(MaxLengthError {
max_length: N,
actual_length: count,
})
} else {
Ok(())
}
}
}
変換も定義したくなったとき
これまでのvalidate
メソッドは純粋な検証のみを行っていました。しかし、実際のアプリケーションでは、バリデーションと同時に値の正規化や変換も行いたい場合があります。
例えば:
- 全角スペースを半角スペースに変換する
- 文字列の前後の空白文字を削除する
- 不正な文字を取り除く
- 文字の大小変換を行う
このようなケースでは、検証だけでなく値の変換も必要になります。そこで、Validator
トレイトを拡張しましょう。
注意: parse
メソッドを導入することで、このトレイトは単純な検証を超えて値の変換も行うようになります。より正確にはParser
という名前が適切かもしれませんが、この記事では一貫性のためValidator
という名前を維持します。
pub trait Validator<T> {
type Error: std::fmt::Display;
fn name() -> Cow<'static, str>;
fn description() -> Cow<'static, str>;
fn parse(value: T) -> Result<T, Self::Error>;
}
validate
メソッドをparse
メソッドに変更しました。この変更により:
- 参照ではなくムーブで値を受け取るため、効率的な変換が可能になります
- もちろんムーブが効率的かどうかはTに依存します。厳密な効率を求めたいのであれば&mut Tを取るべきでしょう
- 戻り値として変換後の値を返すことができます
- 検証のみの場合でも、そのまま値を返すことで同じインターフェースを保てます
Visibleバリデータ
変換が必要なバリデータの例として、Unicode文字カテゴリを使用した可視文字バリデータを実装してみましょう。
pub enum Visible {}
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
#[error("{0}")]
pub enum InvisibleError {
#[error("String contains invisible characters: {0}")]
InvisibleCharacters(NonEmptyString),
#[error("String is empty or contains spaces only")]
EmptyOrWhitespace,
}
impl Validator<String> for Visible {
type Error = InvisibleError;
fn name() -> Cow<'static, str> {
Cow::Borrowed("Visible")
}
fn description() -> Cow<'static, str> {
Cow::Borrowed(
"String must contain only visible characters. spaces are allowed, but control characters and invisible Unicode characters are not",
)
}
fn parse(value: String) -> Result<String, Self::Error> {
fn normalize(c: char) -> Option<char> {
use unicode_general_category::{GeneralCategory::*, get_general_category};
match c {
' ' | ' ' => Some(' '), // 全角スペースを半角スペースに正規化
_ if !matches!(get_general_category(c), SpaceSeparator | Control | Format) => Some(c),
_ => None,
}
}
let (valid_chars, error_chars) = value.chars().fold(
(String::with_capacity(value.len()), String::new()),
|(mut valid, mut error), c| {
match normalize(c) {
Some(normalized) => valid.push(normalized),
None => error.extend(c.escape_unicode()),
}
(valid, error)
},
);
if let Ok(error_chars) = NonEmptyString::try_from(error_chars) {
return Err(InvisibleError::InvisibleCharacters(error_chars));
}
if !valid_chars.chars().any(|c| !c.is_whitespace()) {
return Err(InvisibleError::EmptyOrWhitespace);
}
Ok(valid_chars)
}
}
この実装では以下の処理を行っています:
- 文字単位の検証と変換: 各文字について、Unicode文字カテゴリを判定
- 正規化: 全角スペース(' ')を半角スペース(' ')に変換
- エラー収集: 不正な文字をエスケープして収集
- 最終チェック: 空白文字のみの場合はエラー
この例が示すように、parse
メソッドは単純な検証を超えて、文字列の正規化処理を実現できます。
NonEmptyとMaxLengthの更新
既存のバリデータもparse
メソッドに更新しましょう。
impl Validator<String> for NonEmpty {
type Error = EmptyError;
fn name() -> Cow<'static, str> {
Cow::Borrowed("NonEmpty")
}
fn description() -> Cow<'static, str> {
Cow::Borrowed("String must not be empty")
}
fn parse(value: String) -> Result<String, Self::Error> {
if value.is_empty() {
Err(EmptyError)
} else {
Ok(value)
}
}
}
impl<const N: usize> Validator<String> for MaxLength<N> {
type Error = MaxLengthError;
fn name() -> Cow<'static, str> {
Cow::Owned(format!("MaxLength{N}"))
}
fn description() -> Cow<'static, str> {
Cow::Owned(format!("String must not exceed {N} characters"))
}
fn parse(value: String) -> Result<String, Self::Error> {
let count = value.chars().count();
if count > N {
Err(MaxLengthError {
max_length: N,
actual_length: count,
})
} else {
Ok(value)
}
}
}
これらの場合は変換は行わず、検証のみを行って値をそのまま返しています。
バリデータの合成
単一のバリデーションだけでなく、複数のバリデーションを組み合わせたい場合があります。例えば、「空ではなく、可視文字のみで、20文字以下」といった複合的な制約です。
Validatorの定義を見ると、逐次的にパースしていくようなバリデータの合成を定義してみたくなりませんか?なりますよねということで、バリデーションの合成を型レベルで定義してみましょう。
Compose構造体
バリデータの合成を実現するために、Compose
構造体を定義します。
/// A validator that composes two validators, applying them sequentially so that the output of the first is passed as input to the second.
pub struct Compose<V1, V2> {
_marker: std::marker::PhantomData<fn() -> (V1, V2)>,
}
impl<V1, V2, T, E1, E2> Validator<T> for Compose<V1, V2>
where
V1: Validator<T, Error = E1>,
V2: Validator<T, Error = E2>,
E1: std::fmt::Display + From<E2>,
{
type Error = E1;
fn name() -> Cow<'static, str> {
let name1 = V1::name();
let name2 = V2::name();
if name1.is_empty() {
return name2;
}
if name2.is_empty() {
return name1;
}
Cow::Owned(format!("{name1}{name2}"))
}
fn description() -> Cow<'static, str> {
let desc1 = V1::description();
let desc2 = V2::description();
if desc1.is_empty() {
return desc2;
}
if desc2.is_empty() {
return desc1;
}
Cow::Owned(format!("{desc1} and {desc2}"))
}
fn parse(input: T) -> Result<T, Self::Error> {
let res = V1::parse(input)?;
let res = V2::parse(res)?;
Ok(res)
}
}
Rustは使用されていない型パラメータを許さないため、PhantomDataを使用します。詳細については以下の記事をご覧ください。
compose!マクロ
複数のバリデータを簡潔に合成するために、compose!
マクロも定義できます。
右結合で合成されるように定義します。
#[macro_export]
macro_rules! compose {
($v1:ty, $v2:ty) => {
$crate::validators::Compose<$v1, $v2>
};
($v1:ty, $v2:ty, $($rest:tt)*) => {
$crate::compose!($crate::validators::Compose<$v1, $v2>, $($rest)*)
};
}
Emptyバリデータとエラー型合成
Compose
構造体の型制約を再度見てみましょう。
impl<V1, V2, T, E1, E2> Validator<T> for Compose<V1, V2>
where
V1: Validator<T, Error = E1>,
V2: Validator<T, Error = E2>,
E1: std::fmt::Display + From<E2>, // ←ここ
{
type Error = E1;
// ...
}
この制約 E1: From<E2>
により、第2バリデータのエラー型 E2
を第1バリデータのエラー型 E1
に変換できる必要があります。
そこで登場するのが Empty
バリデータです。Empty
は常に成功するプレースホルダーバリデータで、バリデータを合成エラー型に持ち上げるために使用します。
/// A validator that does not perform any validation and always succeeds.
/// This can be useful for defining composed error types.
pub struct Empty<E>(std::marker::PhantomData<fn() -> E>);
impl<T, E> Validator<T> for Empty<E>
where
E: std::fmt::Display,
{
type Error = E;
fn name() -> Cow<'static, str> {
Cow::Borrowed("")
}
fn description() -> Cow<'static, str> {
Cow::Borrowed("")
}
fn parse(input: T) -> Result<T, Self::Error> {
Ok(input)
}
}
Empty<E>
は任意のエラー型 E
をパラメータとして受け取り、常に成功します。名前と説明は空文字列を返すため、合成時に他のバリデータの情報のみが使用されます。
thiserror::Errorによるエラー型統合
ここで問題となるのが、異なるバリデータのエラー型をどのように統合するかです。幸い、Rustのエコシステムには複合的なバリデーションエラーを表現するのに便利なツールがあります。そう、thiserror
です。
thiserror::Error
と #[from]
属性を使用することで、個別のエラー型を一つの統合エラー型にまとめることができます。
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
#[error("Validation error: {0}")]
enum ValidationError {
MaxLengthExceeded(#[from] MaxLengthError),
Invisible(#[from] InvisibleError),
}
#[from]
属性により、個別のエラー型から ValidationError
への自動変換が生成されます。これは以下のコードと同等です:
impl From<MaxLengthError> for ValidationError {
fn from(err: MaxLengthError) -> Self {
ValidationError::MaxLengthExceeded(err)
}
}
impl From<InvisibleError> for ValidationError {
fn from(err: InvisibleError) -> Self {
ValidationError::Invisible(err)
}
}
実用例
これらの仕組みを使用した実際の例を示します。
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
#[error("Validation error: {0}")]
enum ValidationError {
MaxLengthExceeded(#[from] MaxLengthError),
Invisible(#[from] InvisibleError),
}
type Max20VisibleValidator = compose![Empty<ValidationError>, MaxLength<20>, Visible];
type Max20VisibleString = ValidatedString<Max20VisibleValidator>;
この例では、Max20VisibleString
は以下の処理を順番に実行します:
- MaxLength<20>: 20文字以下かチェック
- Visible: 可視文字のみを含み、全角スペースを半角スペースに正規化
合成により、複数の制約と変換を一度に適用できます。
ValidatedStringとしての統合
最後に、これらのバリデータを使用する型ValidatedString
です。
#[derive(Debug, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct ValidatedString<V: Validator<String>> {
inner: String,
_marker: std::marker::PhantomData<fn() -> V>,
}
impl<V: Validator<String>> TryFrom<String> for ValidatedString<V> {
type Error = <V as Validator<String>>::Error;
fn try_from(value: String) -> Result<Self, Self::Error> {
V::parse(value).map(|inner| Self {
inner,
_marker: std::marker::PhantomData,
})
}
}
ValidatedStringはtuple structではないため、単純な文字列としてシリアライズさせるために#[serde(into = "String")]
をつける必要があります。
utoipaとの統合
OpenAPI仕様生成のためのutoipa
クレートとの統合も実現できます。
#[cfg(feature = "utoipa")]
impl<V: Validator<String>> utoipa::PartialSchema for ValidatedString<V> {
fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
utoipa::openapi::ObjectBuilder::new()
.schema_type(utoipa::openapi::schema::Type::String)
.description(Some(V::description()))
.into()
}
}
#[cfg(feature = "utoipa")]
impl<V: Validator<String>> utoipa::ToSchema for ValidatedString<V> {
fn name() -> std::borrow::Cow<'static, str> {
format!("{}String", V::name()).into()
}
}
まとめ
この記事では、Rustにおける制約を強める型の実装方法を、基本的なNewType Patternから高度なバリデーションシステムまで段階的に解説しました。
シンプルなNewType Patternから始まり、const genericsによる柔軟な制約、抽象化されたValidatorトレイト、そして複数のバリデータを合成できるシステムまで、段階的にアプローチを発展させることで、型安全性と実用性を両立したバリデーションシステムを構築できることを示しました。
特に注目すべきは、パースベースのアプローチによって検証と同時に値の正規化や変換を行える点です。全角スペースの半角変換や不正文字の除去など、実際のアプリケーションで頻繁に必要となる処理を型レベルで組み込むことで、より実用的で堅牢なシステムを実現できます。
このような型による制約強化は、現在注目されているAIエージェントによる開発においても有効です。試行錯誤を繰り返しながら開発を進めるAI駆動の開発プロセスでは、コンパイラによる早期の問題検出が重要な役割を果たします。型制約により、AIが生成したコードの品質担保や意図しないバグの早期発見が可能になるでしょう。
また、コンパイル時の型安全性を保ちながら、実行時のバリデーションエラーを最小限に抑え、さらにutoipaとの連携によるAPI文書の自動生成まで実現できる点も、現代的なWeb開発において大きなメリットといえます。
ただし、この記事で後に紹介した手法が必ずしも優れているということではありません。チームの状況や要件、学習コストを慎重に検討した上で、適切なアプローチを選択することが重要です。複雑性が開発効率を阻害する場合は、最初に紹介したシンプルなNewType Patternを宣言マクロで量産する方が現実的な選択肢となるかもしれません。
Discussion