03 - Sistema de Tipos Avanzado
Type Hints, Pydantic V2, Generics, Protocols y Annotated. Alcanzando la seguridad de TypeScript en Python.
1. Type Hints: Más que Decoración
A diferencia de TypeScript donde los tipos se borran en compilación, Python mantiene los tipos accesibles en runtime via __annotations__. Esto permite que frameworks como FastAPI y Pydantic usen los tipos para validación real.
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
# Los tipos están disponibles en runtime
print(greet.__annotations__)
# {'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}
El Dual System de Python
| Capa | Herramienta | Cuándo |
|---|---|---|
| Estática | Pyright/Mypy | Durante desarrollo (IDE, CI) |
| Runtime | Pydantic/FastAPI | Durante ejecución (validación real) |
Implicación Senior: Tus type hints no son solo documentación—son contratos que Pydantic ejecuta.
2. Pydantic V2: Validación en Rust
Pydantic V2 fue reescrito con un core en Rust (pydantic-core), siendo 5-50x más rápido que V1. Es el equivalente a Zod pero con validación en runtime real.
Modelo Básico
// Zod (TypeScript)
import { z } from 'zod';
const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
role: z.enum(['admin', 'user']),
metadata: z.record(z.unknown()).optional(),
});
type User = z.infer<typeof UserSchema>; # Pydantic V2 (Python)
from pydantic import BaseModel, EmailStr, PositiveInt
from typing import Literal
class User(BaseModel):
id: PositiveInt
email: EmailStr
role: Literal['admin', 'user']
metadata: dict | None = None
# El tipo se infiere automáticamente
user = User(id=1, email="a@b.com", role="admin") Coercion Automática (Key Difference)
A diferencia de Zod que por defecto es estricto, Pydantic coerce tipos compatibles:
from pydantic import BaseModel
class Config(BaseModel):
port: int
debug: bool
tags: list[str]
# Pydantic convierte automáticamente
config = Config(
port="8080", # str → int ✅
debug="true", # str → bool ✅
tags="api,web", # str → ❌ Error (no hay coercion a lista)
)
print(config.port) # 8080 (int)
print(config.debug) # True (bool)
Modo Estricto
from pydantic import BaseModel, ConfigDict
class StrictConfig(BaseModel):
model_config = ConfigDict(strict=True)
port: int
debug: bool
# Ahora falla si los tipos no coinciden exactamente
StrictConfig(port="8080", debug="true")
# ValidationError: port: Input should be a valid integer
3. Validadores Avanzados
Field Validators (antes @validator)
from pydantic import BaseModel, field_validator, ValidationInfo
class User(BaseModel):
username: str
password: str
password_confirm: str
@field_validator('username')
@classmethod
def username_alphanumeric(cls, v: str) -> str:
if not v.isalnum():
raise ValueError('must be alphanumeric')
return v.lower()
@field_validator('password_confirm')
@classmethod
def passwords_match(cls, v: str, info: ValidationInfo) -> str:
if 'password' in info.data and v != info.data['password']:
raise ValueError('passwords do not match')
return v
Model Validators (Validación Cruzada)
from pydantic import BaseModel, model_validator
class DateRange(BaseModel):
start_date: date
end_date: date
@model_validator(mode='after')
def check_dates(self) -> 'DateRange':
if self.start_date >= self.end_date:
raise ValueError('start_date must be before end_date')
return self
# mode='before' para validar datos crudos antes de parsing
class RawInput(BaseModel):
data: dict
@model_validator(mode='before')
@classmethod
def extract_nested(cls, values: dict) -> dict:
if 'nested' in values:
return values['nested']
return values
Computed Fields
from pydantic import BaseModel, computed_field
class Rectangle(BaseModel):
width: float
height: float
@computed_field
@property
def area(self) -> float:
return self.width * self.height
@computed_field
@property
def perimeter(self) -> float:
return 2 * (self.width + self.height)
rect = Rectangle(width=10, height=5)
print(rect.model_dump())
# {'width': 10.0, 'height': 5.0, 'area': 50.0, 'perimeter': 30.0}
4. Serialización Personalizada
Field Serializers
from pydantic import BaseModel, field_serializer
from datetime import datetime
class Event(BaseModel):
name: str
timestamp: datetime
@field_serializer('timestamp')
def serialize_timestamp(self, value: datetime) -> str:
return value.isoformat()
@field_serializer('timestamp', when_used='json')
def serialize_timestamp_json(self, value: datetime) -> int:
return int(value.timestamp())
event = Event(name="Deploy", timestamp=datetime.now())
print(event.model_dump()) # timestamp: "2024-02-01T..."
print(event.model_dump_json()) # timestamp: 1706812800
Alias y Serialization Alias
from pydantic import BaseModel, Field
class APIResponse(BaseModel):
# Campo interno: user_id, JSON externo: userId
user_id: int = Field(alias='userId', serialization_alias='userId')
created_at: datetime = Field(serialization_alias='createdAt')
model_config = ConfigDict(populate_by_name=True)
# Acepta ambos nombres en input
resp = APIResponse(userId=1, created_at=datetime.now())
resp = APIResponse(user_id=1, created_at=datetime.now())
# Output usa serialization_alias
print(resp.model_dump(by_alias=True))
# {'userId': 1, 'createdAt': '2024-...'}
5. Generics: Tipado Parametrizado
// TypeScript Generics
interface Repository<T> {
getById(id: string): Promise<T | null>;
create(data: Partial<T>): Promise<T>;
list(filters: FilterOptions): Promise<T[]>;
}
class UserRepository implements Repository<User> {
async getById(id: string): Promise<User | null> {
// ...
}
} # Python Generics
from typing import TypeVar, Generic
from abc import ABC, abstractmethod
T = TypeVar('T')
class Repository(ABC, Generic[T]):
@abstractmethod
async def get_by_id(self, id: str) -> T | None: ...
@abstractmethod
async def create(self, data: dict) -> T: ...
@abstractmethod
async def list(self, filters: dict) -> list[T]: ...
class UserRepository(Repository[User]):
async def get_by_id(self, id: str) -> User | None:
# El IDE sabe que retorna User
... TypeVar con Constraints
from typing import TypeVar
from pydantic import BaseModel
# T debe ser subclase de BaseModel
ModelT = TypeVar('ModelT', bound=BaseModel)
def validate_and_log(model_class: type[ModelT], data: dict) -> ModelT:
instance = model_class.model_validate(data)
print(f"Validated: {instance}")
return instance
# El IDE infiere el tipo de retorno correctamente
user = validate_and_log(User, {"id": 1, "email": "a@b.com"})
# user es de tipo User, no Any
Generic Pydantic Models
from pydantic import BaseModel
from typing import Generic, TypeVar
DataT = TypeVar('DataT')
class PaginatedResponse(BaseModel, Generic[DataT]):
items: list[DataT]
total: int
page: int
page_size: int
@computed_field
@property
def has_more(self) -> bool:
return self.page * self.page_size < self.total
# Uso en FastAPI
@app.get("/users", response_model=PaginatedResponse[User])
async def list_users(page: int = 1) -> PaginatedResponse[User]:
users = await user_repo.list(page=page)
return PaginatedResponse(
items=users,
total=100,
page=page,
page_size=20
)
6. Protocols: Duck Typing Estático
Los Protocol de Python son el equivalente a las interfaces estructurales de TypeScript. Permiten duck typing con verificación estática.
// TypeScript: Structural typing por defecto
interface Readable {
read(): string;
}
function process(r: Readable) {
console.log(r.read());
}
// Cualquier objeto con read() funciona
const file = { read: () => "content" };
process(file); // ✅ # Python: Protocol para structural typing
from typing import Protocol
class Readable(Protocol):
def read(self) -> str: ...
def process(r: Readable) -> None:
print(r.read())
# Cualquier objeto con read() funciona
class File:
def read(self) -> str:
return "content"
process(File()) # ✅ Sin herencia explícita Protocol vs ABC
from typing import Protocol
from abc import ABC, abstractmethod
# ABC: Requiere herencia explícita
class RepositoryABC(ABC):
@abstractmethod
async def save(self, entity: dict) -> None: ...
class UserRepoABC(RepositoryABC): # DEBE heredar
async def save(self, entity: dict) -> None: ...
# Protocol: No requiere herencia (duck typing)
class RepositoryProtocol(Protocol):
async def save(self, entity: dict) -> None: ...
class UserRepoProtocol: # NO hereda
async def save(self, entity: dict) -> None: ...
# Ambos son válidos para type hints
def use_repo(repo: RepositoryProtocol) -> None: ...
use_repo(UserRepoProtocol()) # ✅
runtime_checkable
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
class Connection:
def close(self) -> None:
print("Closed")
# Permite isinstance() checks
conn = Connection()
if isinstance(conn, Closeable): # ✅ Funciona
conn.close()
7. Annotated: Metadatos en Tipos
Annotated permite adjuntar metadatos a tipos sin cambiar su semántica. Es la base de la DI de FastAPI.
// NestJS: Decoradores en parámetros
@Controller('users')
class UsersController {
@Get(':id')
getUser(
@Param('id', ParseIntPipe) id: number,
@Query('include') include?: string,
) {
// ...
}
} # FastAPI: Annotated con metadatos
from typing import Annotated
from fastapi import Path, Query, Depends
@app.get("/users/{id}")
async def get_user(
id: Annotated[int, Path(ge=1, description="User ID")],
include: Annotated[str | None, Query()] = None,
db: Annotated[Session, Depends(get_db)],
) -> User:
# ... Creando Tipos Reutilizables
from typing import Annotated
from pydantic import Field, AfterValidator
# Validador personalizado
def validate_username(v: str) -> str:
if not v.isalnum():
raise ValueError('Username must be alphanumeric')
return v.lower()
# Tipo reutilizable con validación
Username = Annotated[
str,
Field(min_length=3, max_length=20),
AfterValidator(validate_username)
]
# Tipo para IDs positivos
PositiveId = Annotated[int, Field(gt=0, description="Positive integer ID")]
# Uso en modelos
class CreateUserRequest(BaseModel):
username: Username
email: EmailStr
# Uso en endpoints
@app.get("/users/{user_id}")
async def get_user(user_id: PositiveId) -> User:
...
Composición de Validadores
from typing import Annotated
from pydantic import AfterValidator, BeforeValidator
def strip_whitespace(v: str) -> str:
return v.strip()
def lowercase(v: str) -> str:
return v.lower()
def validate_not_empty(v: str) -> str:
if not v:
raise ValueError('Cannot be empty')
return v
# Composición: se ejecutan en orden
CleanString = Annotated[
str,
BeforeValidator(strip_whitespace),
AfterValidator(lowercase),
AfterValidator(validate_not_empty),
]
class Input(BaseModel):
value: CleanString
Input(value=" HELLO ") # value = "hello"
Input(value=" ") # ValidationError
8. Discriminated Unions (Tagged Unions)
El equivalente a las discriminated unions de TypeScript:
// TypeScript
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
}
} # Python con Pydantic
from typing import Literal, Annotated, Union
from pydantic import BaseModel, Field
class Circle(BaseModel):
kind: Literal['circle'] = 'circle'
radius: float
class Rectangle(BaseModel):
kind: Literal['rectangle'] = 'rectangle'
width: float
height: float
Shape = Annotated[
Union[Circle, Rectangle],
Field(discriminator='kind')
]
def area(shape: Shape) -> float:
match shape:
case Circle(radius=r):
return 3.14159 * r ** 2
case Rectangle(width=w, height=h):
return w * h Discriminated Union en API
from typing import Literal, Union, Annotated
from pydantic import BaseModel, Field
class EmailNotification(BaseModel):
type: Literal['email'] = 'email'
to: EmailStr
subject: str
body: str
class SMSNotification(BaseModel):
type: Literal['sms'] = 'sms'
phone: str
message: str
class PushNotification(BaseModel):
type: Literal['push'] = 'push'
device_token: str
title: str
body: str
Notification = Annotated[
Union[EmailNotification, SMSNotification, PushNotification],
Field(discriminator='type')
]
@app.post("/notify")
async def send_notification(notification: Notification) -> dict:
# Pydantic parsea al tipo correcto basándose en 'type'
match notification:
case EmailNotification():
return await send_email(notification)
case SMSNotification():
return await send_sms(notification)
case PushNotification():
return await send_push(notification)
9. Configuración con Pydantic Settings
from pydantic import Field, PostgresDsn, RedisDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file='.env',
env_file_encoding='utf-8',
env_nested_delimiter='__', # DB__HOST → db.host
case_sensitive=False,
)
# Básicos
debug: bool = False
environment: Literal['dev', 'staging', 'prod'] = 'dev'
# Base de datos
database_url: PostgresDsn
db_pool_size: int = Field(default=5, ge=1, le=20)
# Redis
redis_url: RedisDsn = 'redis://localhost:6379/0'
# Secretos
secret_key: str = Field(min_length=32)
jwt_algorithm: str = 'HS256'
jwt_expiration_minutes: int = 30
@computed_field
@property
def is_production(self) -> bool:
return self.environment == 'prod'
# Singleton
settings = Settings()
# .env
DEBUG=true
ENVIRONMENT=dev
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
SECRET_KEY=your-super-secret-key-at-least-32-chars
10. Type Checking en CI
Configuración de Pyright (Recomendado)
# pyproject.toml
[tool.pyright]
pythonVersion = "3.12"
typeCheckingMode = "strict"
reportMissingTypeStubs = false
reportUnknownMemberType = false
# Excluir paths
exclude = ["**/__pycache__", ".venv", "alembic"]
Configuración de Mypy
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
CI con GitHub Actions
- name: Type Check
run: |
uv run pyright src/
# o: uv run mypy src/
11. Tabla Comparativa: TypeScript vs Python Types
| Concepto | TypeScript | Python |
|---|---|---|
| Sistema | Erasure (compile-time only) | Retained (runtime accessible) |
| Validación Runtime | Zod, io-ts (externo) | Pydantic (integrado) |
| Inferencia | Excelente | Buena (mejorando) |
| Generics | <T> | TypeVar + Generic[T] |
| Interfaces | interface / type | Protocol / TypedDict |
| Union Tags | Discriminated unions | Literal + Field(discriminator=) |
| Nullable | T | null | undefined | T | None |
| Type Guard | is / asserts | TypeGuard / TypeIs (3.13) |
| Decorators | Stage 3 / Experimental | Estable (PEP 614) |
Conclusión
El sistema de tipos de Python, combinado con Pydantic V2, ofrece:
- Validación real en runtime — No solo hints, contratos ejecutables
- Performance — Core en Rust, 5-50x más rápido que alternativas
- Composabilidad —
Annotatedpermite construir tipos complejos - Inferencia IDE — Pyright/Pylance dan autocompletado completo
Pattern Senior: Define tipos reutilizables con Annotated, usa Protocol para contratos, y Generic para componentes parametrizados. Esto te dará la misma (o mejor) experiencia que TypeScript.
En el siguiente capítulo, profundizaremos en asyncio y cómo las corrutinas de Python difieren del Event Loop de Node.js.