Módulo 3 22 min de lectura

03 - Sistema de Tipos Avanzado

Type Hints, Pydantic V2, Generics, Protocols y Annotated. Alcanzando la seguridad de TypeScript en Python.

#typing #pydantic #generics #protocols #type-hints

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

CapaHerramientaCuándo
EstáticaPyright/MypyDurante desarrollo (IDE, CI)
RuntimePydantic/FastAPIDurante 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

JavaScript/TypeScript
// 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>;
Python
# 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

JavaScript/TypeScript
// 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
# 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.

JavaScript/TypeScript
// 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
# 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.

JavaScript/TypeScript
// NestJS: Decoradores en parámetros
@Controller('users')
class UsersController {
@Get(':id')
getUser(
  @Param('id', ParseIntPipe) id: number,
  @Query('include') include?: string,
) {
  // ...
}
}
Python
# 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:

JavaScript/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
# 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

ConceptoTypeScriptPython
SistemaErasure (compile-time only)Retained (runtime accessible)
Validación RuntimeZod, io-ts (externo)Pydantic (integrado)
InferenciaExcelenteBuena (mejorando)
Generics<T>TypeVar + Generic[T]
Interfacesinterface / typeProtocol / TypedDict
Union TagsDiscriminated unionsLiteral + Field(discriminator=)
NullableT | null | undefinedT | None
Type Guardis / assertsTypeGuard / TypeIs (3.13)
DecoratorsStage 3 / ExperimentalEstable (PEP 614)

Conclusión

El sistema de tipos de Python, combinado con Pydantic V2, ofrece:

  1. Validación real en runtime — No solo hints, contratos ejecutables
  2. Performance — Core en Rust, 5-50x más rápido que alternativas
  3. ComposabilidadAnnotated permite construir tipos complejos
  4. 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.