Saltar al contenido
tipado.py · devschool

Anotaciones de tipo en Python

Lección 27 de 31 · 10 min de lectura · Actualizado el

En esta lección
  1. Anotar variables
  2. Anotar funciones
  3. Colecciones
  4. Python no comprueba los tipos
  5. mypy: el comprobador de tipos
  6. Valores que pueden faltar: | None
  7. Tipos más precisos con typing
  8. Alias de tipo
  9. Genéricos
  10. Dataclasses con tipos
  11. ¿Merece la pena anotar?
  12. Errores frecuentes
  13. Resumen

Python es un lenguaje de tipado dinámico: una variable puede guardar un número y, más tarde, un texto, y nadie se queja hasta que algo falla al ejecutar. Desde Python 3.5 puedes añadir anotaciones de tipo (type hints) que dicen qué tipo de dato esperas en cada sitio. No cambian cómo se ejecuta el programa, pero hacen el código más fácil de leer, permiten que el editor te avise de errores mientras escribes y que herramientas como mypy encuentren fallos antes de ejecutar. En esta lección aprenderás a anotar tu código con la sintaxis moderna de Python 3.12.

Anotar variables

Se escribe el nombre, dos puntos y el tipo:

nombre: str = "Lucía"
edad: int = 19
altura: float = 1.68
matriculada: bool = True

Para los tipos básicos casi nunca hace falta: Python y el editor ya deducen el tipo a partir del valor (edad = 19 es claramente un int). Las anotaciones de variables son útiles sobre todo cuando la variable empieza vacía y el tipo no es evidente:

notas: list[float] = []
precios: dict[str, float] = {}

Anotar funciones

Donde las anotaciones más ayudan es en las funciones: cada parámetro lleva su tipo y, tras una flecha ->, el tipo de lo que devuelve.

def precio_final(precio: float, descuento: float = 0) -> float:
    return round(precio * (1 - descuento / 100), 2)

def saludar(nombre: str) -> None:
    print(f"Hola, {nombre}")

print(precio_final(80, 25))   # 60.0
saludar("Iker")               # Hola, Iker
  • Un parámetro con valor por defecto se anota igual: descuento: float = 0.
  • -> None indica que la función no devuelve nada.
  • Donde se pide float, se acepta también un int (80), porque todo entero vale como número decimal.

Solo con leer la primera línea sabes cómo usar la función, sin tener que estudiar su código. Además, el editor (VS Code, PyCharm) usará esa información para autocompletar y para avisarte si le pasas algo raro.

Colecciones

Entre corchetes se indica el tipo de lo que contienen:

AnotaciónSignificado
list[int]Lista de enteros
set[str]Conjunto de cadenas
dict[str, float]Diccionario con claves str y valores float
tuple[str, int]Tupla de exactamente dos elementos: un str y un int
tuple[int, ...]Tupla de enteros de cualquier longitud
list[dict[str, int]]Lista de diccionarios
def media(notas: list[float]) -> float:
    return sum(notas) / len(notas)

def nota_maxima(alumnos: dict[str, float]) -> tuple[str, float]:
    nombre = max(alumnos, key=alumnos.get)
    return nombre, alumnos[nombre]

print(media([7, 8.5, 6]))                        # 7.166666666666667
print(nota_maxima({"Ana": 8.5, "Luis": 9.1}))   # ('Luis', 9.1)

Nota: en código antiguo verás from typing import List, Dict, Tuple y anotaciones como List[int]. Desde Python 3.9 se usan directamente list, dict y tuple en minúscula.

Python no comprueba los tipos

Esto es lo más importante de la lección: las anotaciones no se comprueban al ejecutar. Son información para las personas y las herramientas, no para el intérprete:

def doble(n: int) -> int:
    return n * 2

print(doble("ja"))   # jaja

Aunque doble pide un int, Python ejecuta la función con una cadena sin protestar. Las anotaciones se guardan en el atributo __annotations__, pero nadie las mira a menos que tú uses una herramienta para ello. Si necesitas validar los datos que llegan al programa (lo que escribe el usuario, lo que viene de un archivo), sigue haciéndolo con if y raise, como en excepciones.

mypy: el comprobador de tipos

mypy es un programa que lee tu código sin ejecutarlo y comprueba que los tipos encajan. Se instala con pip, mejor dentro de un entorno virtual:

pip install mypy
mypy tienda.py

Supón que tienda.py contiene esto:

def precio_final(precio: float, descuento: float = 0) -> float:
    return round(precio * (1 - descuento / 100), 2)

total = precio_final("80")
mensaje: str = precio_final(50)

mypy responde:

tienda.py:4: error: Argument 1 to "precio_final" has incompatible type "str"; expected "float"  [arg-type]
tienda.py:5: error: Incompatible types in assignment (expression has type "float", variable has type "str")  [assignment]
Found 2 errors in 1 file (checked 1 source file)

Te dice el archivo, la línea y qué no encaja. Son errores que, sin anotaciones, solo descubrirías al ejecutar ese trozo de código, quizá semanas después y en el ordenador de un cliente.

Consejo: en VS Code, la extensión de Python (con Pylance) hace este tipo de comprobaciones mientras escribes y subraya los errores en rojo. mypy es útil para comprobar el proyecto entero de una vez o hacerlo automáticamente antes de subir cambios.

Valores que pueden faltar: | None

Muchas funciones devuelven un resultado o nada. Se anota con la barra vertical |, que significa “o”:

def buscar_telefono(agenda: dict[str, str], nombre: str) -> str | None:
    return agenda.get(nombre)

agenda = {"Lucía": "600111222"}
telefono = buscar_telefono(agenda, "Pablo")

if telefono is None:
    print("No está en la agenda")
else:
    print(telefono.replace("600", "+34 600"))
# No está en la agenda

Gracias a la anotación, mypy sabe que telefono puede ser None y te avisa si intentas llamar a telefono.replace(...) sin comprobarlo antes, uno de los errores más habituales (AttributeError: 'NoneType' object has no attribute...). Dentro del else, en cambio, ya sabe que es un str: a esto se le llama estrechar el tipo.

La barra sirve para cualquier combinación: int | str acepta un entero o una cadena.

Nota: en código anterior a Python 3.10 verás Optional[str] (equivale a str | None) y Union[int, str] (equivale a int | str), importados de typing.

Tipos más precisos con typing

El módulo typing añade herramientas para describir datos con más detalle.

Literal: solo unos valores concretos

from typing import Literal

def pedir_cafe(tamano: Literal["pequeño", "mediano", "grande"]) -> float:
    precios = {"pequeño": 1.2, "mediano": 1.5, "grande": 1.9}
    return precios[tamano]

print(pedir_cafe("grande"))   # 1.9

Si escribes pedir_cafe("gigante"), mypy lo marca como error, y el editor te sugiere los tres valores válidos al escribir.

TypedDict: diccionarios con forma fija

Cuando un diccionario tiene siempre las mismas claves (por ejemplo, datos que vienen de un JSON), TypedDict describe qué claves tiene y de qué tipo es cada una:

from typing import TypedDict

class Ruta(TypedDict):
    nombre: str
    km: float
    desnivel: int

ruta: Ruta = {"nombre": "Pico Jano", "km": 11.5, "desnivel": 780}
print(ruta["km"] * 2)   # 23.0

En ejecución sigue siendo un diccionario normal, pero mypy detectará una clave mal escrita (ruta["kms"]) o un valor de otro tipo.

Callable: funciones como parámetro

Cuando un parámetro es una función, se anota con Callable[[tipos de los parámetros], tipo devuelto]:

from collections.abc import Callable

def aplicar_a_todos(precios: list[float], operacion: Callable[[float], float]) -> list[float]:
    return [operacion(p) for p in precios]

def con_iva(p: float) -> float:
    return round(p * 1.21, 2)

print(aplicar_a_todos([10, 20], con_iva))   # [12.1, 24.2]

Callable[[float], float] se lee: “una función que recibe un float y devuelve un float”. Es lo que viste en funciones avanzadas al pasar funciones como argumentos.

Any: cualquier cosa

Any desactiva la comprobación para ese valor: mypy acepta cualquier operación sobre él. Úsalo solo como último recurso, por ejemplo con datos de una librería que no tiene anotaciones.

Alias de tipo

Si una anotación es larga o se repite mucho, dale un nombre con type (Python 3.12):

type Coordenada = tuple[float, float]
type Inventario = dict[str, list[int]]

def distancia_aprox(a: Coordenada, b: Coordenada) -> float:
    return round(((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2) ** 0.5, 3)

print(distancia_aprox((43.46, -3.80), (43.35, -4.05)))   # 0.273

Un buen nombre (Coordenada) explica qué representan los datos mejor que tuple[float, float]. En versiones anteriores a la 3.12 se escribía simplemente Coordenada = tuple[float, float].

Genéricos

A veces una función trabaja con listas de cualquier tipo, pero quieres decir que el tipo que devuelve es el mismo que el de los elementos. Para eso se usa una variable de tipo, que se declara entre corchetes después del nombre de la función:

def primero[T](elementos: list[T]) -> T:
    return elementos[0]

nombre = primero(["Ana", "Luis"])   # mypy sabe que es str
numero = primero([3, 1, 2])         # mypy sabe que es int
print(nombre.upper(), numero + 1)   # ANA 4

T es como un hueco: si le pasas una list[str], T vale str y la función devuelve un str. Se llaman genéricos y los usan mucho las librerías. En código anterior a 3.12 verás la forma antigua: T = TypeVar("T") antes de la función.

Dataclasses con tipos

En clases viste que @dataclass necesita anotaciones para saber qué atributos tiene la clase. Con tipos precisos, mypy comprueba también cómo creas los objetos:

from dataclasses import dataclass, field

@dataclass
class Socio:
    nombre: str
    cuota: float
    actividades: list[str] = field(default_factory=list)
    email: str | None = None

    def apuntar(self, actividad: str) -> None:
        self.actividades.append(actividad)

s = Socio("Marta", 29.9)
s.apuntar("escalada")
print(s)
# Socio(nombre='Marta', cuota=29.9, actividades=['escalada'], email=None)

Si escribes Socio("Marta", "29.9") o s.apuntar(3), mypy lo marca. En las clases normales se anotan igual los parámetros de __init__ y de los métodos; self no se anota.

¿Merece la pena anotar?

No es obligatorio, y en un script de 20 líneas puede ser excesivo. Pero en cuanto un programa crece o trabajáis varias personas, las anotaciones se pagan solas: documentan el código, el editor autocompleta mejor y muchos errores aparecen al escribir en lugar de al ejecutar. Una estrategia razonable:

  1. Anota siempre los parámetros y el retorno de las funciones.
  2. Anota las variables solo cuando el tipo no sea evidente (colecciones vacías, None al principio).
  3. Pasa mypy de vez en cuando, o deja que tu editor lo haga por ti.

Errores frecuentes

  • Creer que Python comprueba los tipos: no lo hace. Sin mypy o el editor, una anotación incorrecta pasa desapercibida.
  • Olvidar | None: si la función puede devolver None (por ejemplo, con dict.get o un return en un if), dilo en la anotación.
  • Usar list sin el tipo interior: list a secas no dice nada del contenido; escribe list[str].
  • Confundir tuple[int] con tuple[int, ...]: la primera es una tupla de un elemento.
  • Abusar de Any: anula la comprobación y oculta errores.
  • Escribir -> None cuando la función sí devuelve algo: mypy avisará de que el valor devuelto no encaja.

Resumen

ElementoEjemplo
Variableedad: int = 19
Funcióndef f(precio: float, n: int = 1) -> float:
No devuelve nada-> None
Coleccioneslist[int], dict[str, float], tuple[str, int]
Puede faltarstr | None (antes Optional[str])
Varios tiposint | str (antes Union[int, str])
Valores fijosLiteral["S", "M", "L"]
Diccionario con formaclass Ruta(TypedDict): ...
Función como parámetroCallable[[float], float]
Aliastype Coordenada = tuple[float, float]
Genéricodef primero[T](xs: list[T]) -> T:
Comprobarpip install mypy y mypy archivo.py

En la siguiente lección aprenderás a trabajar con días, horas y duraciones con el módulo datetime.

Pon a prueba lo que has aprendido

[Python] ¿Qué ocurre al ejecutar print(doble("ja")) con la función anotada def doble(n: int) -> int: return n * 2?

[Python] Una función devuelve agenda.get(nombre), que puede ser None. ¿Cuál es la anotación de retorno correcta?

[Python] ¿Qué anotación describe una tupla de enteros de cualquier longitud?

[Python] ¿Qué significa Callable[[float], float] en un parámetro?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.