TypedDict, NewType, Literal y Final en Python

El sistema de tipos de Python tiene dos velocidades: la que usas cuando solo quieres anotar int o str, y la que usas cuando necesitas que el verificador entienda semántica, no solo estructura. TypedDict, NewType, Literal y Final viven en esa segunda velocidad. Son herramientas quirúrgicas —cada una resuelve un problema concreto que las anotaciones básicas no pueden expresar.

TypedDict: diccionarios con forma conocida

Cuando recibes un diccionario de configuración, de una respuesta JSON o de cualquier fuente externa, el tipo dict[str, Any] es honesto pero inútil: le dices al verificador “no sé nada de este objeto, confía en mí”. TypedDict es la alternativa: describes exactamente qué claves existen y qué tipo tiene cada valor.

Internamente no crea ninguna clase real en runtime. Un TypedDict en memoria es un dict ordinario —no hay overhead, no hay validación automática en ejecución. El beneficio es exclusivamente estático: mypy, pyright y Pylance saben qué claves puedes leer y escribir, y te avisan si te equivocas.

NewType: identidad nominal sobre tipos existentes

int puede ser un ID de usuario, un ID de pedido, un contador, un timestamp… y el verificador no ve diferencia. NewType crea un tipo distinguible para el verificador aunque en runtime sea exactamente el mismo int. Es tipado nominal superficial: no puedes pasar un int pelado donde se espera UserId sin una conversión explícita, lo que convierte errores lógicos en errores de tipos.

Literal: valores exactos, no tipos genéricos

str como tipo de un método HTTP es demasiado amplio. Literal['get', 'post', 'put'] restringe el valor a ese conjunto finito. El verificador puede hacer exhaustiveness checking, autocompletar valores válidos y rechazar strings arbitrarios. Cuando el dominio de valores es pequeño y conocido, Literal es más expresivo y más seguro que str.

Final: señal de inmutabilidad para el verificador

Final declara que una variable o atributo no debe reasignarse después de su primera asignación. No es una constante en el sentido de C (const) —Python no lo impide en runtime— pero el verificador rechaza cualquier reasignación posterior. Úsalo para constantes de módulo, valores de configuración que no deben cambiar, y para comunicar intención a quien lea el código después.

El momento adecuado para cada herramienta: TypedDict cuando modelas datos estructurados sin querer crear una clase completa; NewType cuando dos tipos comparten representación pero son semánticamente distintos; Literal cuando los valores válidos son enumerables y pocos; Final cuando la reasignación sería un bug silencioso.

Si abusas de NewType en tipos que sí se mezclan legítimamente, acabas luchando contra el verificador en lugar de trabajar con él.

from __future__ import annotations

from typing import Final, Literal, NewType, TypedDict


# ── TypedDict ────────────────────────────────────────────────────────────────

class DatabaseConfig(TypedDict):
    host: str
    port: int
    name: str


class AppConfig(TypedDict, total=False):  # total=False → todas las claves son opcionales
    debug: bool
    max_connections: int


# ── NewType ───────────────────────────────────────────────────────────────────

UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)


def get_user(user_id: UserId) -> str:
    return f"User #{user_id}"


# ── Literal ───────────────────────────────────────────────────────────────────

HttpMethod = Literal["GET", "POST", "PUT", "DELETE"]


def make_request(url: str, method: HttpMethod = "GET") -> dict[str, str]:
    return {"url": url, "method": method}


# ── Final ─────────────────────────────────────────────────────────────────────

MAX_RETRIES: Final = 3
API_VERSION: Final[str] = "v2"


# ── Uso integrado ─────────────────────────────────────────────────────────────

def connect(config: DatabaseConfig) -> str:
    # El verificador sabe que config["host"] es str y config["port"] es int
    return f"postgresql://{config['host']}:{config['port']}/{config['name']}"


def process_order(order_id: OrderId, user_id: UserId) -> str:
    return f"Order {order_id} for user {user_id}"


def main() -> None:
    db: DatabaseConfig = {"host": "localhost", "port": 5432, "name": "mydb"}
    print(connect(db))

    uid = UserId(42)
    oid = OrderId(99)

    print(get_user(uid))
    print(process_order(oid, uid))

    print(make_request("https://api.example.com/items", "POST"))

    # MAX_RETRIES = 5  ← mypy/pyright rechazarían esta línea


if __name__ == "__main__":
    main()

Qué hace cada decisión y por qué importa

DatabaseConfig usa TypedDict sin total=False, lo que significa que todas las claves son obligatorias —si construyes el dict sin port, el verificador lo detecta. AppConfig usa total=False para claves opcionales, un patrón habitual en configuración con valores por defecto.

La separación entre UserId y OrderId es el punto central de NewType. Si intentas llamar get_user(oid) pasando un OrderId, mypy lo rechaza aunque ambos sean int en runtime. Pero si tienes un int ordinario n, tampoco puedes pasarlo directamente: debes ser explícito con UserId(n). Esa fricción es intencionada —fuerza que el código documente de dónde vienen los identificadores.

HttpMethod como alias de Literal es un patrón muy limpio: defines el tipo una vez, lo reutilizas en todas las funciones que lo necesiten, y cuando añadas un método nuevo actualizas un solo lugar. Pyright además puede advertirte en match/case si olvidas cubrir algún valor.

Final sin tipo explícito (Final = 3) deja que el verificador infiera int. Final[str] es más explícito y necesario cuando el tipo inferido podría ser más estrecho de lo que quieres (Literal["v2"] en lugar de str).

Errores que debes conocer

Error: pasar un int ordinario donde se espera un NewType —parece que debería funcionar porque son el mismo tipo en runtime, pero el verificador los distingue.

# ❌ Wrong
def delete_user(user_id: UserId) -> None:
    ...

raw_id: int = 7
delete_user(raw_id)  # mypy: Argument 1 has incompatible type "int"; expected "UserId"

# ✅ Right
delete_user(UserId(raw_id))  # conversión explícita en el punto de entrada

La conversión UserId(raw_id) es un no-op en runtime, pero hace visible en el código el momento exacto en que decides que ese entero representa un usuario.


Error: usar dict[str, Any] en lugar de TypedDict y luego intentar agregar claves que no existen en la definición —con TypedDict el verificador rechaza claves desconocidas, pero si mezclas con Any pierdes toda esa protección.

# ❌ Wrong
def setup(config: dict[str, Any]) -> None:
    host = config["hots"]  # typo silencioso, nunca lo detecta el verificador

# ✅ Right
def setup(config: DatabaseConfig) -> None:
    host = config["hots"]  # mypy: TypedDict "DatabaseConfig" has no key "hots"

El verificador conoce exactamente las claves de DatabaseConfig, así que el typo "hots" se convierte en un error estático antes de que el código llegue a producción.


Error: reasignar una variable Final dentro de una función creyendo que es solo una anotación cosmética.

# ❌ Wrong
MAX_RETRIES: Final = 3

def reset_config() -> None:
    MAX_RETRIES = 5  # mypy: Cannot assign to final name "MAX_RETRIES"

# ✅ Right
def reset_config() -> None:
    local_retries = 5  # usa una variable local si necesitas un valor diferente

Final no genera ningún error en runtime —Python simplemente reasigna la variable— pero el verificador lo captura, que es exactamente el punto: detectar ese bug antes de ejecutar.

128

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio