Genéricos y tipos custom en Python

Durante mucho tiempo he trabajado con Typescript lo que evidentemente ha condicionado mis opiniones en cuanto al uso o no de lenguajes tipados. Hoy os quiero hablar de como poder usar genéricos y tipos custom en Python para mejorar la robustez de nuestro código.

Genéricos

La existencia de genéricos en un lenguaje siempre es un tema de debate, hace bien poco se introdujeron en Go y la polémica estuvo servida.

En Python es relativamente sencillo de crear genéricos, más allá de la propia complejidad del concepto, y empezar a usarlos en nuestro código.

Para crear un tipo genérico simplemente debemos hacer uso de la clase TypeVar:

from typing import TypeVar, Generic

T = TypeVar('T')

class Stack(Generic[T]):
    def __init__(self) -> None:
        self.items: list[T] = []

    def push(self, item: T) -> None:
        self.items.append(item)

    def pop(self) -> T:
        return self.items.pop()

En este sencillo ejemplo se ve claramente como haciendo uso del genérico vamos a poder controlar que nuestras listas tengan todos los elementos del mismo tipo:

stack = Stack[int]()
stack.push(2)          # OK
stack.pop()            # OK
stack.push('x')        # Type error

Otro punto muy interesante de la clase TypeVar es que nos permite asociarla (bound) a clases ya existentes con el objetivo de limitar el uso de nuestro métodos a ciertos tipos, aquí un ejemplo:

from typing import TypeVar

T = TypeVar('T', bound='Shape')

class Shape:
    def set_scale(self: T, scale: float) -> T:
        self.scale = scale
        return self

class Circle(Shape):
    def set_radius(self, r: float) -> 'Circle':
        self.radius = r
        return self

class Square(Shape):
    def set_width(self, w: float) -> 'Square':
        self.width = w
        return self

circle: Circle = Circle().set_scale(0.5).set_radius(2.7)
square: Square = Square().set_scale(0.5).set_width(3.2)

Esta asociación se puede realizar también a nivel de tipos primitivos y de forma múltiple:

from typing import TypeVar

AnyStr = TypeVar('AnyStr', str, bytes)

def concat(x: AnyStr, y: AnyStr) -> AnyStr:
    return x + y

concat('a', 'b')    # Okay
concat(b'a', b'b')  # Okay
concat(1, 2)        # Error!

Evitar tipos primitivos con NewType

Dentro del mundo del desarrollo existe un tipo de smell conocido como Primitive Obsession, el cual se basa en el uso intensivo de tipos primitivos para representar entidades que distan bastante de ser primitivas. El ejemplo habitual es usar int para representar monedas o str para representar fechas.

En Python es muy sencillo wrappear estos primitivos en objetos mucho más semánticos haciendo uso de la clase NewType.

Sin necesidad de hacer nada y de forma completamente automática este keyword nos genera un constructor:

Currency = NewType('Currency', int)

def add(currency: Currency) -> Currency:
    ...

Currency('user')		# Fails type check

currency = Currency(42)		# OK
add(currency)			# OK

value: int = Currency(5) + 1

El ejemplo es realmente sencillo, pero se ve la idea. Si queremos dar un primer paso para evitar el uso de primitivos podemos introducir estos NewTypes y en el futuro cuando el código esté consolidado moverlos a objetos de dominio mas complejos.

TypeDict

¿Cuántas veces hemos tenido que leer JSONs que vienen de APIs externas que no controlamos? Ya os lo digo yo, todos los días xD.

En Python es bastante habitual recibir estos objetos y consumirlos como si fueran diccionarios. Esta forma de acceder a los datos es bastante pobre en cuanto al tipado se refiere y por ello es fácil cometer errores accediendo a los mismos.

Para mejorar esta forma de consumir la información existe la clase TypeDict, la cual nos permite definir diccionarios con determinadas claves asociadas a determinados tipos, mejor lo vemos en un ejemplo:

from typing_extensions import TypedDict

Movie = TypedDict('Movie', {'name': str, 'year': int})

movie: Movie = {'name': 'Blade Runner', 'year': 1982}


name = movie['name']  # Okay; type of name is str
year = movie['year']  # Okay; type of year is int
director = movie['director']  # Error: 'director' is not a valid key

De esta forma pese a que no podemos controlar la estructura de los datos que recibimos si podemos modelar internamente su representación lo que nos va a facilitar mucho la vida y nos va a evitar muchos dolores de cabeza.

Es importante destacar que si usamos TypeDict mypy espera que existan todas las keys y no solo algunas de forma parcial.

# Error: 'year' missing
toy_story: Movie = {'name': 'Toy Story'}

Conclusiones

Espero que os haya gustado la idea de mejorar los tipos en Python haciendo uso de genéricos o tipos custom creados ad-hoc para nuestro código.

Creo que es una forma de trabajar que nos aporta una red de seguridad extra en cuanto a tipos se refiere y eso siempre suma.

¡Un saludo!