# Pydantic 高度な型・カスタムバリデータ実践ガイド：Annotated で再利用可能な「ドメインの型」を作る

> Pydantic v2公式ドキュメントに忠実に、AfterValidator/BeforeValidator/WrapValidator/PlainValidatorのAnnotatedパターン、StringConstraintsとannotated_typesの制約型、__get_pydantic_core_schema__によるカスタム型、判別共用体・RootModel・ジェネリックモデルまで、再利用可能なドメイン型を設計する高度なバリデーションを実コードで解説します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: Python, Pydantic, 型安全, バリデーション, データモデリング, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/pydantic-custom-types-annotated-validators-advanced-guide

## 要点

- Annotatedパターン（AfterValidator等）でバリデーションを「型」に焼き込むと、Username=Annotated[str, ...]のように再利用可能なドメイン型になり、@field_validatorの重複を消せる
- 複数バリデータの実行順は公式定義どおり：before/wrapは右→左、afterは左→右。視覚的な並び順とは一致しない
- 制約はconstr/conlistではなくAnnotated+StringConstraints/annotated_typesで書く。constrはPydantic 3.0で非推奨予定
- __get_pydantic_core_schema__でstr/intを継承した独自型に検証を埋め込め、InstanceOf/SkipValidationで検証の強弱を制御できる
- 多態データは判別共用体、ルートがlist/dictのデータはRootModel、汎用ラッパはGeneric[T]で表現し、不正な状態を型で表現不能にする

---

## **導入：検証ロジックは「散らかる」**

[Pydantic v2 実践ガイド](/blog/pydantic-v2-production-validation-type-safety) で、`@field_validator` と `@model_validator` の基礎を扱いました。あれで多くの検証は書けます。けれど、実務でモデルが増えてくると、ある種の臭いが漂い始めます。

- ユーザー名の正規化（前後空白除去・小文字化・長さチェック）を、`User`・`Profile`・`Comment` の各モデルで**同じ `@field_validator` をコピペ**している。
- 「正の金額」「日本の郵便番号」「在庫数」のような**業務上の値の型**が、ただの `int` / `str` として散らばり、制約がモデル定義の中に埋もれている。
- 同じ検証なのに、書く場所（デコレータ vs `Field` 制約）がモデルごとにバラバラで、どこを直せばいいのか分からない。

これは DRY（繰り返さない）と SRP（単一責任）が崩れているサインです。本記事のテーマは、Pydantic v2 の **`Annotated` パターン**を中心に、**検証ロジックを「再利用可能なドメインの型」へ昇格させる**こと。「メールアドレス」「ユーザー名」「正の整数」を**一度だけ定義し、どこでも使い回せる型**にすれば、検証は散らからず、変更は一点に集約されます（CLAUDE.md でいう ETC＝Easy To Change の実践です）。

[公式ドキュメント](https://pydantic.dev/docs/validation/latest/concepts/validators/) に忠実でありながら、それより一段わかりやすく、`Annotated` バリデータ・制約型・カスタム型・判別共用体・`RootModel`・ジェネリックモデルまでを、実コードで体系化します。

---

## **1. `Annotated` バリデータ：検証を「型」に焼き込む**

v2 には、バリデーションの書き方が 2 つあります。`@field_validator` デコレータ（モデルに紐づく）と、**`Annotated` パターン（型に紐づく）**です。後者の本質は、**「この型は、こう検証される」を型の定義そのものに埋め込める**ことです。

```python
from typing import Annotated
from pydantic import AfterValidator, BaseModel


def is_even(value: int) -> int:
    if value % 2 == 1:
        raise ValueError(f"{value} は偶数ではありません")
    return value


class Model(BaseModel):
    number: Annotated[int, AfterValidator(is_even)]
```

`AfterValidator(is_even)` は、`int` の標準検証が**終わった後**に `is_even` を走らせます。ポイントは、これを**名前付きの型として切り出せる**こと。

```python
from typing import Annotated
from pydantic import AfterValidator, BaseModel


def _normalize_username(value: str) -> str:
    cleaned = value.strip().lower()
    if not 3 <= len(cleaned) <= 30:
        raise ValueError("ユーザー名は3〜30文字です")
    return cleaned


# ✅ 再利用可能な「ドメインの型」を一度だけ定義する
Username = Annotated[str, AfterValidator(_normalize_username)]


class User(BaseModel):
    name: Username


class Comment(BaseModel):
    author: Username      # 同じ検証を、コピペせず再利用
    body: str
```

公式も*「アノテーションパターンを使う主な利点の一つは、バリデータを再利用できることです」*と明言しています。`@field_validator` を 3 つのモデルにコピペする代わりに、`Username` を 3 箇所で使う。検証ロジックの**単一の真実（Single Source of Truth）**が手に入ります。

### **H3: 4 つのモード——`After` / `Before` / `Plain` / `Wrap`**

`Annotated` に置けるバリデータは 4 種類。それぞれ「標準検証に対してどこで走るか」が違います。

| バリデータ | 走るタイミング | 受け取る値 | 主な用途 |
| --- | --- | --- | --- |
| `AfterValidator` | 標準検証の**後** | 型が保証された値 | 検証・正規化（第一選択） |
| `BeforeValidator` | 標準検証の**前** | 生の入力（`Any`） | 入力形式の事前整形 |
| `PlainValidator` | 標準検証の**代わり** | 生の入力（`Any`） | 検証ロジックを完全に自前実装 |
| `WrapValidator` | 標準検証を**包む** | `Any` ＋ `handler` | 例外捕捉・フォールバック |

```python
from typing import Annotated, Any
from pydantic import BaseModel, BeforeValidator


def ensure_list(value: Any) -> Any:
    # "a,b,c" のような単一文字列も、検証前にリストへ整形する
    if isinstance(value, str):
        return [t.strip() for t in value.split(",")]
    return value


class Article(BaseModel):
    tags: Annotated[list[str], BeforeValidator(ensure_list)]
```

> ⚠️ **`PlainValidator` は検証を打ち切る**：`PlainValidator` は標準検証の「代わり」に走るため、その後の型検証は行われません。`Annotated[int, PlainValidator(f)]` と書いても、`f` が返した値が `int` であることは**保証されない**——`f` の責任で型を担保する必要があります。性能・安全の両面から、まずは `AfterValidator` を第一に検討してください。

### **H3: 実行順——「視覚的な並び」と「実際の順」は違う**

複数のバリデータを重ねたとき、その実行順は直感に反します。公式の定義は明確です。

> *before and wrap validators are run from right to left, and after validators are then run from left to right.*

```python
from typing import Annotated
from pydantic import AfterValidator, BaseModel, BeforeValidator, WrapValidator


class Model(BaseModel):
    name: Annotated[
        str,
        AfterValidator(runs_3rd),   # after：左→右
        AfterValidator(runs_4th),
        BeforeValidator(runs_2nd),  # before/wrap：右→左
        WrapValidator(runs_1st),
    ]
# 実行順：runs_1st → runs_2nd → runs_3rd → runs_4th
```

**`before` / `wrap` は右から左、`after` は左から右**。同じリストの中で向きが逆になります。視覚的な上から下への並びと一致しないので、複数を重ねるときは順序に意識的になってください。

---

## **2. 制約型：`constr` ではなく `Annotated` + 制約で書く**

「3〜30 文字」「正の整数」「正規表現マッチ」といった制約は、3 通りで書けます。**どれを選ぶべきか**が要点です。

```python
from decimal import Decimal
from typing import Annotated
from annotated_types import Gt, Len
from pydantic import BaseModel, Field, StringConstraints


class Product(BaseModel):
    # ① Field メタデータで制約（最も一般的）
    price: int = Field(gt=0)
    sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
    amount: Decimal = Field(max_digits=7, decimal_places=2)

    # ② annotated_types の制約クラス（再利用しやすい）
    quantity: Annotated[int, Gt(0)]
    tags: Annotated[list[str], Len(max_length=10)]

    # ③ StringConstraints（文字列の整形＋制約をまとめて）
    code: Annotated[str, StringConstraints(
        strip_whitespace=True, to_upper=True, pattern=r"^[A-Z]+$"
    )]
```

`annotated_types` の `Gt(0)` や `Len(...)` を使えば、第1章と同じ要領で**再利用可能な制約型**を作れます。

```python
from typing import Annotated
from annotated_types import Gt

PositiveInt = Annotated[int, Gt(0)]   # プロジェクト全体で使い回せる
```

> ⚠️ **`constr` / `conlist` は使わない**：v1 由来の `constr(min_length=3)` や `conlist(...)` という「制約付き型を返す関数」は、まだ動きます。しかし公式は `constr` について*「この関数は `Annotated` ＋ `StringConstraints` の使用を推奨し、非推奨とする。**Pydantic 3.0 で deprecated になる**」*と明記しています。新規コードでは必ず `Annotated[str, StringConstraints(...)]` 形式で書き、将来の破壊的変更を避けてください。

**なぜ `Annotated` 形式が優れているのか？**
`constr(...)` は型チェッカー（mypy / Pyright）から見ると「実行時に型を返す関数」で、静的解析と相性が悪い。一方 `Annotated[str, ...]` は、**静的には素直に `str`** として扱われ、追加情報（制約）はメタデータとして横に乗るだけ。**型チェッカーには `str`、Pydantic には制約付き `str`** という、両取りが成立します。

---

## **3. カスタム型：`__get_pydantic_core_schema__` で独自型に検証を埋め込む**

`Annotated` バリデータは「既存の型に検証を足す」仕組みでした。一歩進めて、**`str` や `int` を継承した独自クラスそのものに、検証ロジックを内蔵**させることもできます。これが `__get_pydantic_core_schema__` です。

```python
from typing import Any
from pydantic_core import CoreSchema, core_schema
from pydantic import GetCoreSchemaHandler, TypeAdapter


class Username(str):
    """検証込みのユーザー名型。str を継承しているのでそのまま文字列として使える。"""

    @classmethod
    def __get_pydantic_core_schema__(
        cls, source_type: Any, handler: GetCoreSchemaHandler
    ) -> CoreSchema:
        # handler(str) で「まず str として検証」、その後に cls を適用する
        return core_schema.no_info_after_validator_function(cls, handler(str))


ta = TypeAdapter(Username)
result = ta.validate_python("alice")
print(type(result))  # <class '__main__.Username'>
```

**`handler(str)` が肝**です。基底スキーマ（ここでは `str` の検証）を自分で組み立てるのではなく、`handler` に作らせる。そのうえで `no_info_after_validator_function` で「検証後に `cls` を適用する」と宣言します。

### **H3: 再利用可能なマーカークラス——「自前の `AfterValidator`」**

`__get_pydantic_core_schema__` を持つ**マーカークラス**を作れば、`Annotated` に乗せられる独自の検証注釈を量産できます。

```python
from dataclasses import dataclass
from typing import Annotated, Any, Callable
from pydantic_core import CoreSchema, core_schema
from pydantic import BaseModel, GetCoreSchemaHandler


@dataclass(frozen=True)
class MyAfterValidator:
    func: Callable[[Any], Any]

    def __get_pydantic_core_schema__(
        self, source_type: Any, handler: GetCoreSchemaHandler
    ) -> CoreSchema:
        return core_schema.no_info_after_validator_function(
            self.func, handler(source_type)
        )


# 標準ライブラリの関数すら、そのまま検証として使える
Lowercased = Annotated[str, MyAfterValidator(str.lower)]


class Account(BaseModel):
    email: Lowercased
```

### **H3: 検証の強弱を切り替える——`InstanceOf` と `SkipValidation`**

```python
from pydantic import BaseModel, InstanceOf, SkipValidation


class Fruit:
    ...


class Basket(BaseModel):
    # InstanceOf：型強制せず「そのクラスのインスタンスか」だけを検証する
    fruits: list[InstanceOf[Fruit]]
    # SkipValidation：このフィールドの検証を意図的にスキップする
    raw_labels: list[SkipValidation[str]]
```

`InstanceOf[T]` は「`isinstance(v, T)` だけを要求し、coercion はしない」型。任意の Python オブジェクト（ORM エンティティ等）をそのまま受けたいときに有効です。`SkipValidation[T]` は逆に「検証を省く」マーカー。

> ⚠️ **`SkipValidation` は最後に置く**：公式によれば `SkipValidation` はスキーマを `any_schema`（＝何でも通す）に変えるため、その後に他の注釈を重ねても効かないことがあります。*「適用する型の最後の注釈にすべき」*とされています。検証を外すのは型安全の穴になるので、用途を限定してください。

---

## **4. 判別共用体：多態データを安全にモデリングする**

「種類によって持つフィールドが違うデータ」——イベント（`click` / `purchase` / `signup`）、通知（`email` / `sms` / `push`）、図形（`circle` / `rect`）——は、**判別共用体**で表すのが定石です。

```python
from typing import Literal, Union
from pydantic import BaseModel, Field


class EmailNotification(BaseModel):
    channel: Literal["email"]
    to_address: str
    subject: str


class SmsNotification(BaseModel):
    channel: Literal["sms"]
    phone_number: str


class PushNotification(BaseModel):
    channel: Literal["push"]
    device_token: str


class Envelope(BaseModel):
    # channel の値で、検証すべきメンバーを一意に確定する
    notification: Union[
        EmailNotification, SmsNotification, PushNotification
    ] = Field(discriminator="channel")


Envelope.model_validate(
    {"notification": {"channel": "sms", "phone_number": "090-0000-0000"}}
)
# → SmsNotification として検証される
```

各メンバーに**共通の `Literal` 判別フィールド**を持たせ、`Field(discriminator=...)` で指定する。メンバーの判別キーの**名前が違う**場合や、**型で振り分けたい**場合は、`Discriminator` に判別関数を渡し、各メンバーを `Tag` で標識します（[パフォーマンス最適化ガイド](/blog/pydantic-v2-performance-optimization-guide) の第3章に実例があります）。

**なぜこれが優れているのか？**
判別共用体は、**「不正な状態を表現不能にする」**設計の典型です。`SmsNotification` に `subject`（メール用）を渡そうとすればエラーになり、`channel="sms"` なのに `phone_number` が欠けていればその欠落だけが指摘される。`if data["channel"] == "email": ...` のような手続き的な分岐をコードから追い出し、**型の構造そのものに業務ルールを語らせる**。第7章で触れるジェネリックや `model_validator` と並ぶ、ドメインモデリングの中核技法です。性能上も「総当たり」を避けられる利点があります。

---

## **5. `RootModel`：ルートが `list` / `dict` のデータを型にする**

外部 API が、オブジェクトではなく**トップレベルで配列や辞書を返す**ことはよくあります（`["a", "b", "c"]` や `{"alice": 30, "bob": 25}`）。こうした「ルートがコレクション」のデータは `RootModel` で型にします（v1 の `__root__` の後継）。

```python
from pydantic import RootModel


Tags = RootModel[list[str]]
ScoreByName = RootModel[dict[str, int]]

tags = Tags(["python", "rust"])
print(tags.root)               # ['python', 'rust']  ← 中身は .root でアクセス
print(tags.model_dump_json())  # ["python","rust"]
```

ルート要素がモデルでない検証には、`RootModel` のほか `TypeAdapter(list[User])` も使えます。**再利用するなら `RootModel`（名前が付き、メソッドを足せる）、その場限りなら `TypeAdapter`** と使い分けると整理できます。

> 💡 **`.root` でアクセスする**：`RootModel` の中身は属性 `.root` から取り出します。`tags[0]` のように添字で引きたい場合は、サブクラス化して `__getitem__` / `__iter__` を自分で定義します。ルート型は 1 つだけで、通常のフィールドと混在はできません。

---

## **6. ジェネリックモデル：再利用可能なラッパを型安全に作る**

「成功フラグ＋データ＋メタ情報」のような**汎用レスポンス封筒**を、中身の型を変えながら使い回したい——`Generic[T]` の出番です。

```python
from typing import Generic, TypeVar
from pydantic import BaseModel

DataT = TypeVar("DataT")


class ApiResponse(BaseModel, Generic[DataT]):
    success: bool
    data: DataT
    request_id: str


class User(BaseModel):
    id: int
    name: str


# パラメータ化した時点で、data の検証が効くようになる
resp = ApiResponse[User].model_validate(
    {"success": True, "data": {"id": 1, "name": "alice"}, "request_id": "req_x"}
)
print(resp.data.name)  # "alice" ← data は User として検証済み
```

> ⚠️ **パラメータ化して初めて効く**：検証が働くのは `ApiResponse[User]` のように**型引数を与えた形**だけです。素の `ApiResponse` のままだと `DataT` は実質 `Any` 扱いになり、`data` は検証されません。レスポンス封筒・ページネーション・`Result[T, E]` 風の型など、「形は同じで中身が変わる」構造を DRY に表現できます（Python 3.12 以降なら `class ApiResponse[DataT](BaseModel)` の PEP 695 構文も使えます）。

---

## **7. どれをいつ使うか——意思決定の早見表**

ここまでの技法は競合しません。**「検証したい対象の粒度」で選ぶ**のが整理のコツです。

| やりたいこと | 使う仕組み | 理由 |
| --- | --- | --- |
| 1 つの値・型の検証を**再利用**したい | `Annotated` ＋ `AfterValidator` 等 | 型に焼き込めば DRY。複数モデルで共有できる |
| 単純な制約（長さ・範囲・正規表現） | `Field(...)` / `annotated_types` / `StringConstraints` | 宣言的。`constr` は使わない |
| **特定モデルの**1 フィールド検証 | `@field_validator` | モデル固有のロジックはデコレータが素直 |
| **複数フィールドにまたがる**検証 | `@model_validator` | 不変条件をモデルに集約（パスワード一致など） |
| `str`/`int` 継承の**独自型**に検証を内蔵 | `__get_pydantic_core_schema__` | ライブラリ的な再利用。マーカークラス化も可 |
| 種類で形が変わる**多態データ** | 判別共用体（`discriminator`） | 不正な状態を表現不能にする＋高速 |
| ルートが `list`/`dict` のデータ | `RootModel` / `TypeAdapter` | トップレベルがモデルでない検証 |
| 中身が変わる**汎用ラッパ** | `Generic[T]` | レスポンス封筒等を型安全に再利用 |

最後に、`@model_validator(mode="wrap")` を使えば**モデル全体の検証を包んで**、失敗時のロギングやフォールバックを挟めます。クロスフィールド検証と「壊れた入力の観測」を両立したいときに有効です。

```python
import logging
from typing import Any
from typing import Self
from pydantic import BaseModel, ModelWrapValidatorHandler, ValidationError, model_validator


class Order(BaseModel):
    item_id: int
    quantity: int

    @model_validator(mode="wrap")
    @classmethod
    def log_on_failure(cls, data: Any, handler: ModelWrapValidatorHandler[Self]) -> Self:
        try:
            return handler(data)
        except ValidationError:
            logging.warning("Order の検証に失敗: %r", data)  # PII に注意
            raise
```

---

## **結論：検証を「型」に昇格させ、不正を表現不能にする**

高度なバリデーションの目的は、技巧を凝らすことではありません。**検証ロジックを再利用可能な型へ昇格させ、不正な状態をそもそも作れなくする**こと——これに尽きます。本記事の要点を再掲します。

1. **`Annotated` パターン**（`AfterValidator` 等）で検証を型に焼き込み、`Username` のような**再利用可能なドメイン型**を作る。実行順は before/wrap が右→左、after が左→右。
2. **制約は `Annotated` ＋ `StringConstraints` / `annotated_types`** で書く。`constr` / `conlist` は使わない（3.0 で非推奨予定）。
3. **`__get_pydantic_core_schema__`** で独自型に検証を内蔵し、`InstanceOf` / `SkipValidation` で検証の強弱を制御する。
4. **判別共用体**で多態データを、**`RootModel`** でルートコレクションを、**`Generic[T]`** で汎用ラッパを、それぞれ型安全に表現する。

これらは「動くコード」を「10 年運用できるコード」に変える設計力そのものです。検証が型に集約されていれば、仕様変更は一点で済み、誤った使い方はコンパイル前・検証時に弾かれます。

公式の一次情報として、以下を本記事の観点で再読することをお勧めします。

- [Validators](https://pydantic.dev/docs/validation/latest/concepts/validators/)
- [Types](https://pydantic.dev/docs/validation/latest/concepts/types/)
- [Fields](https://pydantic.dev/docs/validation/latest/concepts/fields/)
- [Unions](https://pydantic.dev/docs/validation/latest/concepts/unions/)
- [Models（RootModel / Generic）](https://pydantic.dev/docs/validation/latest/concepts/models/)

---

### **型安全なドメインモデリングのご相談**

筆者は、経済産業大臣賞を受賞した B2B SaaS のバックエンドを Python で設計・実装し、複雑な商流・価格・在庫のドメインを型で表現してきました。「不正な状態を表現不能にする」モデリングは、バグの発生源を構造的に断ち、保守コストを長期にわたって下げます。Pydantic v2 を用いた**ドメイン型の設計・既存モデルの型安全化・検証ロジックの集約**といった、事業の信頼性に直結する基盤づくりを、生成 AI を活用して高速かつ高品質に進めます。お気軽にご相談ください。
