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