Day 21 · ~12m

Custom Types

Creating reusable validation types with Annotated, AfterValidator, and BeforeValidator.

🧑‍💻

I keep writing the same validator on multiple models — like stripping whitespace and lowercasing email fields. Is there a way to create a reusable "validated email" type that I can use anywhere?

👩‍🏫

Yes — with Annotated and validator functions. You create a custom type once and use it in any model:

from typing import Annotated
from pydantic import AfterValidator

def normalize_email(v: str) -> str:
    v = v.strip().lower()
    if "@" not in v:
        raise ValueError("must contain @")
    return v

Email = Annotated[str, AfterValidator(normalize_email)]

Now Email is a reusable type. Use it anywhere you'd use str:

from pydantic import BaseModel

class User(BaseModel):
    name: str
    email: Email

class Contact(BaseModel):
    primary_email: Email
    backup_email: Email

user = User(name="Alice", email="  ALICE@Example.COM  ")
print(user.email)  # "alice@example.com"

One definition, used in multiple models. The validation logic lives with the type, not scattered across model validators.

🧑‍💻

What's the difference between AfterValidator and BeforeValidator?

👩‍🏫

AfterValidator runs after Pydantic's built-in type coercion — you receive a value that's already the declared type. BeforeValidator runs before coercion — you receive the raw input:

from pydantic import BeforeValidator

def parse_comma_list(v):
    if isinstance(v, str):
        return [s.strip() for s in v.split(",")]
    return v

CommaList = Annotated[list[str], BeforeValidator(parse_comma_list)]

class Tags(BaseModel):
    items: CommaList

# Pass a comma-separated string — BeforeValidator converts it to a list
t = Tags(items="python, pydantic, validation")
print(t.items)  # ['python', 'pydantic', 'validation']

# Or pass a list directly — it passes through
t2 = Tags(items=["a", "b"])
print(t2.items)  # ['a', 'b']
🧑‍💻

Can I stack multiple validators on the same type?

👩‍🏫

Yes. They run in order — each one transforms the value and passes it to the next:

def strip_whitespace(v: str) -> str:
    return v.strip()

def must_not_be_empty(v: str) -> str:
    if not v:
        raise ValueError("must not be empty")
    return v

def to_lowercase(v: str) -> str:
    return v.lower()

CleanString = Annotated[str, AfterValidator(strip_whitespace), AfterValidator(must_not_be_empty), AfterValidator(to_lowercase)]
🧑‍💻

Can I add Field constraints to an Annotated type too?

👩‍🏫

Yes. Annotated can hold multiple metadata items — validators, Field constraints, and more:

from pydantic import Field

PositiveInt = Annotated[int, Field(gt=0, description="A positive integer")]
ShortString = Annotated[str, Field(min_length=1, max_length=50), AfterValidator(lambda v: v.strip())]

class Config(BaseModel):
    retries: PositiveInt
    name: ShortString

Custom types with Annotated are the Pydantic way to build a library of reusable, self-documenting validation rules. Define them once in a types.py module and import them across your project.

Practice your skills

Sign up to write and run code in this lesson.

Already have an account? Sign in