Skip to content

Type Safety

Ferro provides comprehensive type safety through deep integration with Pydantic V2 and Python's type system.

Pydantic Integration

Ferro models ARE Pydantic models:

from ferro import Model
from pydantic import BaseModel

class User(Model):
    username: str
    age: int

# User inherits from BaseModel
assert issubclass(User, BaseModel)  # True

# All Pydantic features work
user = User(username="alice", age=30)
print(user.model_dump())  # {"username": "alice", "age": 30}
print(user.model_dump_json())  # '{"username":"alice","age":30}'

Runtime Validation

Pydantic validates all data at runtime:

# Valid
user = User(username="alice", age=30)

# Invalid: type error
try:
    user = User(username="alice", age="thirty")
except ValidationError as e:
    print(e)
    # age: Input should be a valid integer

# Invalid: missing required field
try:
    user = User(username="alice")
except ValidationError as e:
    print(e)
    # age: Field required

Static Type Checking

Ferro provides full type hints for static analyzers (mypy, pyright, pylance):

from ferro import Model

class User(Model):
    username: str
    age: int

# Fetch by PK: definite model instance (raises ModelDoesNotExist if missing)
user: User = await User.get(1)

# Optional PK lookup
maybe_user: User | None = await User.get_or_none(999)

# Autocomplete works
user.username  # ✓ Known attribute
user.invalid   # ✗ Type error

# Query results are typed
users: list[User] = await User.all()
first: User | None = await User.where(User.username == "alice").first()

IDE Autocomplete

Full IDE support with intelligent completions:

user = await User.get(1)

# IDE suggests: username, age, save, delete, refresh, etc.
user.  # <autocomplete>

# Query builder is typed
User.where(
    User.  # <autocomplete: username, age, id>
)

Query Predicates and the Type Checker

Static checkers see your Pydantic annotations, not the FieldProxy instances Ferro's metaclass installs at class-creation time. That means User.archived == False is statically a bool even though it is a QueryNode at runtime, and Pyright or ty will flag it where Query.where expects a QueryNode.

Ferro ships three predicate styles to address this without any model-annotation changes or type-checker plugins:

from ferro.query import col

# Operator (unchanged)
await User.where(User.id == 1).all()

# col() — runtime identity, statically narrows back to FieldProxy[T]
await User.where(col(User.archived) == False).all()

# Lambda — receives a QueryProxy whose attributes return FieldProxy
await User.where(lambda t: t.archived == False).all()

See Typed Query Predicates for the full discussion, including when to reach for each style and how they compose.

Field Type Validation

Ferro validates field types match database types:

from datetime import datetime
from decimal import Decimal
from uuid import UUID

class Order(Model):
    id: UUID  # Validated as UUID
    amount: Decimal  # Validated as Decimal
    created_at: datetime  # Validated as datetime

Custom Validators

Use Pydantic validators:

from pydantic import field_validator

class User(Model):
    username: str
    email: str

    @field_validator('email')
    @classmethod
    def validate_email(cls, v: str) -> str:
        if '@' not in v:
            raise ValueError('Invalid email')
        return v.lower()

# Validation runs automatically
user = await User.create(
    username="alice",
    email="ALICE@EXAMPLE.COM"  # Normalized to lowercase
)

Type Coercion

Pydantic coerces compatible types:

class User(Model):
    age: int
    score: float

# Strings are coerced
user = User(age="30", score="95.5")
assert user.age == 30  # int
assert user.score == 95.5  # float

Generic Types

Ferro supports complex generic types:

from typing import Dict, List, Optional

class User(Model):
    tags: List[str]  # List of strings
    metadata: Dict[str, Any]  # Dictionary
    bio: Optional[str] = None  # Nullable

See Also