Type checking em runtime com isinstance e typing.cast

1. Introdução ao type checking em runtime no Python

Python é uma linguagem de tipagem dinâmica, onde os tipos das variáveis são determinados em tempo de execução. Embora isso ofereça flexibilidade, também pode levar a erros difíceis de depurar quando objetos inesperados são passados para funções. O type checking em runtime — verificar tipos enquanto o programa executa — é essencial para validar dados, construir APIs robustas e implementar duck typing controlado.

As principais ferramentas para isso são:

  • type(): retorna o tipo exato de um objeto
  • isinstance(): verifica se um objeto é de um tipo específico, considerando herança
  • typing.cast(): não faz verificação real, apenas informa o type checker sobre o tipo esperado

Neste artigo, vamos explorar cada uma dessas ferramentas, seus casos de uso e como combiná-las para criar código Python mais seguro e previsível.

2. isinstance: o pilar da verificação de tipos

isinstance() é a função mais recomendada para verificação de tipos em runtime. Diferente de type(), ela respeita hierarquias de classes e funciona com classes abstratas.

# Sintaxe básica
x = 42
print(isinstance(x, int))   # True
print(isinstance(x, float)) # False

# Verificação com tupla de tipos
print(isinstance(x, (int, float)))  # True (aceita int ou float)

# Hierarquia de classes
class Animal:
    pass

class Cachorro(Animal):
    pass

rex = Cachorro()
print(isinstance(rex, Cachorro))  # True
print(isinstance(rex, Animal))    # True (herança)
print(type(rex) == Animal)        # False (type() não considera herança)

3. isinstance com tipos genéricos e containers

Um desafio comum é verificar tipos genéricos como List[int] ou Dict[str, int]. isinstance não funciona diretamente com tipos parametrizados:

from typing import List, Dict, get_origin, get_args

# Isso NÃO funciona
# isinstance([1, 2, 3], List[int])  # TypeError

# Solução com get_origin e get_args (Python 3.8+)
def validate_list_of_ints(obj):
    if not isinstance(obj, list):
        return False
    return all(isinstance(item, int) for item in obj)

# Validando estruturas aninhadas
def validate_user_data(data):
    expected_type = Dict[str, List[int]]
    origin = get_origin(expected_type)  # dict
    args = get_args(expected_type)      # (str, List[int])

    if not isinstance(data, origin):
        return False

    for key, value in data.items():
        if not isinstance(key, args[0]):
            return False
        if not validate_list_of_ints(value):
            return False
    return True

# Exemplo prático
user_scores = {"Alice": [95, 87, 92], "Bob": [78, 85]}
print(validate_user_data(user_scores))  # True

4. typing.cast: afirmação de tipos sem verificação real

typing.cast é uma ferramenta enganosa: ela não faz absolutamente nenhuma verificação em runtime. Seu único propósito é informar ferramentas de análise estática (como mypy) sobre o tipo esperado.

from typing import cast

def process_data(data):
    # cast não valida nada em runtime
    numbers = cast(list[int], data)

    # Se data não for uma lista, isso quebrará em runtime
    return sum(numbers)

# Exemplo onde cast é útil
def get_first_element(items):
    # O type checker não sabe que items[0] é int
    first = items[0]
    return cast(int, first)  # Apenas para o type checker

# Diferença crucial entre cast e isinstance
def safe_process(data):
    if isinstance(data, list):
        # isinstance verificou, agora podemos usar cast com segurança
        numbers = cast(list[int], data)
        return sum(numbers)
    return 0

5. Combinando isinstance e cast para código robusto

O padrão mais eficaz é usar isinstance para verificar em runtime e cast para ajudar o type checker:

class Usuario:
    def __init__(self, nome: str, idade: int):
        self.nome = nome
        self.idade = idade

    def get_info(self) -> str:
        return f"{self.nome}, {self.idade} anos"

def processar_usuario(obj):
    # Guard clause com isinstance
    if not isinstance(obj, Usuario):
        raise TypeError("Esperado um objeto Usuario")

    # cast ajuda o type checker a entender o tipo
    usuario = cast(Usuario, obj)
    return usuario.get_info()

# Exemplo mais complexo com Union
from typing import Union

def processar_valor(valor: Union[int, str, float]):
    if isinstance(valor, int):
        # cast para int permite acesso a métodos específicos
        v = cast(int, valor)
        return v.to_bytes(4, 'big')
    elif isinstance(valor, str):
        return valor.upper()
    elif isinstance(valor, float):
        return round(valor, 2)
    raise TypeError(f"Tipo não suportado: {type(valor)}")

6. Tratamento de tipos complexos: Union, Optional e Any

from typing import Union, Optional, Any

# Union com isinstance
def process_union(value: Union[int, str, None]):
    if isinstance(value, (int, str)):
        # isinstance com tupla funciona para Union
        return f"Processado: {value}"
    elif value is None:
        return "Valor nulo"
    raise TypeError("Tipo não esperado")

# Optional é Union[T, None]
def process_optional(value: Optional[int]):
    # isinstance(value, int) já trata o None implicitamente
    if isinstance(value, int):
        return value * 2
    return 0

# Armadilhas com Any
def problem_with_any(value: Any):
    # Isso NÃO funciona
    # isinstance(value, Any)  # TypeError: Cannot parameterize Any

    # Alternativa: verificar se NÃO é None
    if value is not None:
        return str(value)
    return "None"

7. Performance e boas práticas no type checking runtime

import time
from functools import singledispatch

# Comparação de performance
data = list(range(1000000))

# type() vs isinstance()
start = time.time()
for x in data:
    type(x) is int
print(f"type(): {time.time() - start:.3f}s")

start = time.time()
for x in data:
    isinstance(x, int)
print(f"isinstance(): {time.time() - start:.3f}s")

# Alternativa elegante: singledispatch
@singledispatch
def process(obj):
    raise TypeError(f"Tipo não suportado: {type(obj)}")

@process.register(int)
def _(obj):
    return obj * 2

@process.register(str)
def _(obj):
    return obj.upper()

@process.register(list)
def _(obj):
    return sum(obj)

# Uso
print(process(10))      # 20
print(process("hello")) # HELLO
print(process([1,2,3])) # 6

Boas práticas:
- Prefira isinstance a type() quando herança estiver envolvida
- Use hasattr() apenas para duck typing, não para verificação de tipo
- Evite verificação excessiva em loops internos críticos de performance
- singledispatch é elegante para despacho baseado em tipo sem if-else aninhados

8. Conclusão e integração com ferramentas de type checking

O type checking em runtime em Python segue uma divisão clara:

  • isinstance: verificação real em runtime, ideal para validação de dados e controle de fluxo
  • cast: apenas para análise estática, sem efeito em runtime

Essas técnicas se complementam perfeitamente com ferramentas modernas:

  • mypy e Pyright: usam anotações de tipo e cast para análise estática
  • Pydantic: usa isinstance internamente para validação automática de dados
  • dataclasses com validadores: combinam isinstance com decoradores

Para aprofundamento, explore metaclasses e descritores — técnicas avançadas que permitem controle de tipos em nível de classe, criando sistemas de validação ainda mais sofisticados.

Referências