Tipado en Python: Tipado de clases

Tabla de Contenido

Cuando un proyecto empieza a tener modelos de dominio, servicios y objetos que se pasan entre capas, el tipado de clases en Python se vuelve una herramienta clave para mantener contratos claros. Ya no se trata solo de saber si una variable es str o int; también necesitamos expresar que un método recibe un User, que un atributo guarda un datetime, o que una operación entre contadores devuelve otro contador compatible.

Note

Este artículo es parte de una serie que explica el tipado en Python. A continuación, puedes ver la recopilación de todos los posts de la serie.

Ver serie

En esta entrega nos concentraremos en clases: cómo usarlas como tipos, cómo declarar atributos de instancia, cuándo aplicar ClassVar, y cómo resolver referencias hacia la propia clase con strings, from __future__ import annotations y typing.Self.

Usar clases como tipos en Python

En Python, una clase también puede usarse como anotación de tipo. Esto aplica tanto para clases que tú defines como para clases de la librería estándar. Por ejemplo, el módulo datetime define tipos como date, datetime y timedelta, mientras que uuid define UUID.

import datetime
import uuid

birth_date: datetime.date = datetime.date(2020, 2, 7)
timestamp: datetime.datetime = datetime.datetime(2020, 2, 7, 10, 0)
duration: datetime.timedelta = datetime.timedelta(hours=1)

uid1: uuid.UUID = uuid.uuid1()
uid4: uuid.UUID = uuid.uuid4()

Este código no cambia el comportamiento en tiempo de ejecución. Su valor está en la señal que le entregamos al IDE, al analizador estático y a otros desarrolladores que lean el código.

import datetime

created_at: datetime.datetime = datetime.datetime.now()  # ✅
created_at = "2024-08-12"  # ❌ el contrato dejó de ser datetime.datetime

Info

Las anotaciones no reemplazan la validación de datos externos. Si recibes JSON, entrada de usuario o información desde una API, sigue siendo necesario validar en los bordes del sistema.

Tipar atributos de instancia en la clase

Cuando trabajamos con clases propias, podemos declarar los atributos esperados directamente en el cuerpo de la clase. Esto ayuda a que el analizador entienda qué atributos existen en cada instancia, incluso si se asignan dentro de __init__.

class Counter:
    n: int

    def __init__(self, start: int = 0) -> None:
        self.n = start

    def __repr__(self) -> str:
        return str(self.n)

En este ejemplo, n: int describe un atributo de instancia. Cada objeto Counter tendrá su propio valor de n, asignado en el constructor.

counter = Counter(start=10)
print(counter.n)      # ✅ 10
print(repr(counter))  # ✅ "10"

counter.n = "10"     # ❌ rompe el contrato declarado para la instancia

La anotación en el cuerpo de la clase no inicializa el atributo por sí sola. Si declaras n: int, pero nunca asignas self.n, la instancia no tendrá ese atributo disponible en tiempo de ejecución.

Warning

Anotar un atributo no es lo mismo que crearlo. La anotación describe el contrato; la asignación en __init__ crea el estado real de la instancia.

ClassVar para variables de clase

No todos los atributos declarados en una clase pertenecen a cada instancia. Algunas variables funcionan como configuración compartida para todas las instancias. Para estos casos existe ClassVar, disponible desde typing.

from typing import ClassVar

class Counter:
    STEP: ClassVar[int] = 1
    n: int

    def __init__(self, start: int = 0) -> None:
        self.n = start

    def __repr__(self) -> str:
        return str(self.n)

    def increment(self) -> None:
        self.n += self.STEP

STEP representa una variable de clase. El valor vive en la clase Counter, no como parte del estado individual de cada objeto.

Counter.STEP = 2      # ✅ configuración compartida
counter = Counter(10)
counter.increment()
print(counter.n)      # 12

counter.STEP = 5      # ❌ puede ocultar la variable de clase en esa instancia

Técnicamente Python permite asignar counter.STEP = 5, pero eso crea o modifica un atributo en la instancia, lo cual puede generar confusión. ClassVar permite que herramientas como mypy o Pyright detecten este uso como una señal de diseño incorrecta.

Tip

Usa ClassVar cuando el atributo describe configuración, constantes o metadatos compartidos por todas las instancias de una clase.

Referencias hacia la propia clase con strings

Un caso común aparece cuando un método recibe o devuelve otra instancia de la misma clase. Por ejemplo, podemos definir la suma de dos contadores con __add__.

from typing import ClassVar

class Counter:
    STEP: ClassVar[int] = 1
    n: int

    def __init__(self, start: int = 0) -> None:
        self.n = start

    def __repr__(self) -> str:
        return str(self.n)

    def __add__(self, other: "Counter") -> "Counter":
        return Counter(start=self.n + other.n)

La anotación "Counter" es una referencia adelantada o forward reference. Se escribe como string porque, mientras Python está evaluando el cuerpo de la clase, el nombre Counter todavía no terminó de definirse.

first = Counter(2)
second = Counter(3)

result = first + second
print(result)  # 5

El siguiente diagrama resume el contrato principal de la clase:

classDiagram
    class Counter {
        +int n
        +ClassVar~int~ STEP
        +__init__(int start) None
        +__repr__() str
        +__add__(Counter other) Counter
    }

Esta forma es compatible con versiones antiguas de Python y sigue siendo frecuente en bases de código que necesitan soportar varios entornos.

Evaluación diferida con from __future__ import annotations

Desde Python 3.7 podemos activar la evaluación diferida de anotaciones usando from __future__ import annotations. Con esto, las anotaciones no se resuelven inmediatamente al definir la clase, lo que permite escribir Counter sin comillas.

from __future__ import annotations

from typing import ClassVar

class Counter:
    STEP: ClassVar[int] = 1
    n: int

    def __init__(self, start: int = 0) -> None:
        self.n = start

    def __repr__(self) -> str:
        return str(self.n)

    def __add__(self, other: Counter) -> Counter:
        return Counter(start=self.n + other.n)

La ventaja principal es legibilidad. El código se ve más cercano a una anotación normal y reduce el ruido de comillas en modelos con muchas referencias cruzadas.

# ✅ con evaluación diferida
from __future__ import annotations

class Node:
    parent: Node | None

    def __init__(self, parent: Node | None = None) -> None:
        self.parent = parent

Info

from __future__ import annotations debe aparecer al inicio del archivo, antes de otros imports normales. Es una decisión por módulo, no por clase.

typing.Self para métodos que devuelven la misma clase

Python 3.11 incorporó typing.Self para expresar que un método devuelve una instancia del mismo tipo concreto que lo invoca. Esto es especialmente útil en métodos fluidos, constructores alternativos y operaciones que deberían preservar el subtipo.

from typing import Self

class Counter:
    n: int

    def __init__(self, start: int = 0) -> None:
        self.n = start

    def __repr__(self) -> str:
        return str(self.n)

    def __add__(self, other: Self) -> Self:
        return type(self)(start=self.n + other.n)

Aquí usamos type(self)(...) en lugar de Counter(...) para respetar el tipo concreto de la instancia. Si más adelante existe una subclase de Counter, el método tiene mejores posibilidades de devolver esa misma subclase.

Warning

Si anotas un método con Self, procura que la implementación realmente devuelva el mismo tipo concreto. Anotar Self pero retornar siempre Counter(...) puede engañar al analizador estático.

Herencia: cuando la clase base no es suficiente

El beneficio de Self se ve más claro con herencia. Consideremos una clase base A y una subclase B. Si el método me se anota devolviendo A, el tipo estático del resultado será siempre A, incluso cuando el objeto real sea B.

from __future__ import annotations

class A:
    def me(self) -> A:
        return self

class B(A):
    pass

b = B()
result = b.me()  # tipo estático: A

Ese contrato es correcto, pero pierde información. El método devuelve self, así que cuando lo invoca una instancia de B, el resultado debería conservar el tipo B.

from typing import Self

class A:
    def me(self) -> Self:
        return self

class B(A):
    pass

b = B()
result = b.me()  # tipo estático: B

La diferencia es importante cuando encadenas métodos o cuando la subclase agrega comportamiento adicional.

from typing import Self

class A:
    def me(self) -> Self:
        return self

class B(A):
    def only_b(self) -> str:
        return "B"

b = B()
print(b.me().only_b())  # ✅ el analizador conserva que b.me() es B

Sin Self, el analizador puede asumir que b.me() es A, y por tanto marcar only_b() como un método inexistente.

class A:
    def me(self) -> "A":
        return self

class B(A):
    def only_b(self) -> str:
        return "B"

b = B()
b.me().only_b()  # ❌ para el analizador, me() devuelve A

Tip

Usa Self cuando el contrato del método sea “devuelvo el mismo tipo concreto que recibió la llamada”. Usa el nombre de la clase cuando el método debe devolver explícitamente una instancia de esa clase base.

Cierre

El tipado de clases permite expresar contratos más precisos entre objetos. Puedes usar clases estándar como datetime.datetime o uuid.UUID, declarar atributos de instancia, diferenciar variables compartidas con ClassVar, y manejar referencias hacia la propia clase con strings o evaluación diferida.

Para jerarquías de herencia, typing.Self mejora la precisión del análisis estático porque conserva el tipo concreto de la instancia. Esta diferencia se vuelve relevante cuando diseñas APIs orientadas a objetos, métodos encadenables o modelos que se especializan mediante subclases.

Note

En el próximo artículo veremos cómo aplicar tipado a estructuras de datos para modelar colecciones más expresivas y mantenibles.

Estructuras de datos

Etiquetas :