Type Hints além do básico: Protocol, TypedDict e Generic na prática
Se você já usa def somar(a: int, b: int) -> int: no dia a dia, já deu o primeiro passo com type hints. Mas tem uma pergunta que separa quem usa type hints como "comentário bonito" de quem usa como ferramenta real de design: você já usou um verificador de tipos estático — mypy ou pyright — para pegar um bug antes de rodar o código?
Se a resposta for não, este artigo é para você. Vamos além do str, int e Optional do dia a dia e entrar em três ferramentas que resolvem problemas reais de design de código: Protocol, TypedDict e Generic.
Por que type hints "básicos" não bastam sempre
Type hints simples funcionam bem para funções com parâmetros óbvios. Mas Python tem características — duck typing, dicionários com estrutura fixa, funções genéricas que funcionam para qualquer tipo — que os tipos básicos não conseguem expressar direito. É aí que entram os três recursos deste artigo, cada um resolvendo um problema específico:
Protocolresolve: "essa função aceita qualquer objeto que tenha esse método, não me importa a classe."TypedDictresolve: "esse dicionário sempre tem essas chaves, com esses tipos — não é um dict genérico qualquer."Genericresolve: "essa classe/função funciona para qualquer tipo, mas precisa ser o mesmo tipo do início ao fim."
Vamos ver cada um com exemplos que aparecem de verdade no dia a dia de um backend.
Protocol: tipando duck typing
Python é uma linguagem de duck typing — "se anda como pato e grasna como pato, é um pato". Isso significa que uma função pode aceitar qualquer objeto que tenha os métodos certos, sem se importar com a classe dele:
class Cachorro:
def fazer_som(self) -> str:
return "Au au"
class Pato:
def fazer_som(self) -> str:
return "Quack"
def emitir_som(animal) -> None:
print(animal.fazer_som())
Isso funciona em tempo de execução para qualquer objeto com fazer_som(). Mas como tipar o parâmetro animal? Antes da PEP 544, a única opção "correta" seria criar uma classe base abstrata (ABC) e forçar Cachorro e Pato a herdar dela — o que é um exagero para um caso simples, e ainda por cima exige alterar classes que talvez você nem controle (de uma biblioteca externa, por exemplo).
O Protocol resolve isso com tipagem estrutural: você declara o formato esperado, e qualquer classe que "encaixe" nesse formato é aceita — sem precisar herdar de nada, explicitamente:
from typing import Protocol
class FazSom(Protocol):
def fazer_som(self) -> str: ...
def emitir_som(animal: FazSom) -> None:
print(animal.fazer_som())
emitir_som(Cachorro()) # ✅ mypy aprova, mesmo sem herança
emitir_som(Pato()) # ✅ idem
Note que Cachorro e Pato não sabem que FazSom existe. Isso é o ponto central do Protocol: ele descreve um contrato de comportamento, e o verificador de tipos confere se o objeto cumpre esse contrato, sem exigir uma relação de herança explícita. É o duck typing do Python, só que agora verificável estaticamente.
Onde isso importa de verdade
Protocol brilha especialmente quando você está escrevendo uma função que deveria aceitar "qualquer coisa que se comporte como X", sem acoplar seu código a uma implementação específica:
from typing import Protocol
class Repositorio(Protocol):
def salvar(self, dados: dict) -> None: ...
def buscar(self, id: int) -> dict | None: ...
def processar(repo: Repositorio, dados: dict) -> None:
repo.salvar(dados)
Agora processar funciona com qualquer implementação — um repositório em PostgreSQL, um em memória para testes, um mock — desde que tenha os métodos salvar e buscar. Isso é essencialmente o princípio de inversão de dependência, mas expresso de forma leve, sem a burocracia de criar uma classe abstrata para cada contrato.
TypedDict: dicionários com estrutura fixa
Dicionários em Python são flexíveis por natureza — dict[str, Any] aceita qualquer chave, qualquer valor. Isso é ótimo até o momento em que seu dicionário, na verdade, representa uma estrutura fixa e você perde toda a checagem de tipo:
def criar_pedido(dados: dict) -> None:
print(dados["cliente"])
print(dados["valor_totall"]) # erro de digitação — ninguém avisa
O mypy não tem como saber que dados deveria ter as chaves cliente e valor_total — para ele, é só um dict genérico. O erro de digitação em valor_totall só vai aparecer em tempo de execução, com um KeyError.
O TypedDict, definido na PEP 589, resolve exatamente isso — permite declarar a "forma" exata de um dicionário:
from typing import TypedDict
class Pedido(TypedDict):
cliente: str
valor_total: float
itens: list[str]
def criar_pedido(dados: Pedido) -> None:
print(dados["cliente"])
print(dados["valor_totall"]) # mypy: erro! chave não existe em Pedido
Um detalhe importante: TypedDict não cria uma nova classe em tempo de execução — o objeto continua sendo um dict normal, com todo o comportamento (e a performance) de um dict comum. A checagem é puramente estática, feita pelo mypy/pyright, sem nenhum overhead em runtime. Isso é bem diferente do Pydantic — se você já leu o artigo sobre Pydantic v2 aqui do blog, o BaseModel valida em tempo de execução e paga um custo por isso; o TypedDict só documenta e checa estaticamente, sem custo nenhum quando o código roda.
Campos opcionais
Nem toda chave de um dicionário é obrigatória. Dá para marcar isso com NotRequired:
from typing import TypedDict, NotRequired
class Pedido(TypedDict):
cliente: str
valor_total: float
cupom_desconto: NotRequired[str]
Agora cupom_desconto pode ou não estar presente, e o mypy entende isso corretamente ao acessar a chave.
Quando usar TypedDict em vez de Pydantic
Essa é uma dúvida comum, e a resposta prática é: use TypedDict quando você só precisa de checagem estática, sem validação em tempo de execução — tipicamente para estruturas internas que já vêm de uma fonte confiável (uma função sua, um teste). Use Pydantic quando o dado vem de fora do seu controle (API, arquivo, input de usuário) e precisa ser validado de verdade, não só documentado.
Generic: tipos que se adaptam, mas se mantêm consistentes
Imagine uma função simples que devolve o primeiro item de uma lista:
def primeiro(lista: list) -> object:
return lista[0]
Tipado assim, primeiro([1, 2, 3]) devolve algo do tipo object — o mypy perde toda a informação de que, na verdade, era uma lista de int. Isso obriga a fazer cast manual depois, ou simplesmente perder a checagem de tipo dali em diante.
O Generic (com a sintaxe moderna trazida pela PEP 695, no Python 3.12+) resolve isso permitindo declarar que o tipo de retorno depende do tipo de entrada:
def primeiro[T](lista: list[T]) -> T:
return lista[0]
primeiro([1, 2, 3]) # mypy infere: int
primeiro(["a", "b", "c"]) # mypy infere: str
Aqui T é uma variável de tipo — um placeholder que o mypy substitui pelo tipo real a cada chamada. A função continua genérica (funciona para qualquer tipo), mas o verificador de tipos mantém o rastro de qual tipo específico está em jogo em cada uso.
A sintaxe anterior, para quem está em versões mais antigas do Python
Se seu projeto ainda roda em versões abaixo da 3.12 (bem comum em ambientes de produção que não atualizam a cada release), a sintaxe é um pouco mais verbosa, usando TypeVar explicitamente:
from typing import TypeVar
T = TypeVar("T")
def primeiro(lista: list[T]) -> T:
return lista[0]
O comportamento é idêntico — a PEP 695 só simplificou a sintaxe, não mudou o conceito.
Classes genéricas
O mesmo raciocínio se aplica a classes inteiras. Um caso clássico é uma estrutura de "resultado" que pode conter sucesso ou erro, mantendo o tipo do valor de sucesso:
class Resultado[T]:
def __init__(self, valor: T | None = None, erro: str | None = None) -> None:
self.valor = valor
self.erro = erro
def ok(self) -> bool:
return self.erro is None
def buscar_usuario(id: int) -> Resultado[dict]:
if id <= 0:
return Resultado(erro="id inválido")
return Resultado(valor={"id": id, "nome": "Carlos"})
Quem consome Resultado[dict] sabe, estaticamente, que .valor — quando presente — é um dict. Sem o Generic, essa informação se perderia, e .valor teria que ser tipado como Any, abrindo mão de qualquer checagem.
Colocando tudo para trabalhar junto: um exemplo mais realista
Vamos combinar os três conceitos num cenário parecido com o que aparece em backends de verdade — um repositório genérico, com um contrato definido por Protocol, operando sobre estruturas tipadas com TypedDict:
from typing import Protocol, TypedDict
class Cliente(TypedDict):
id: int
nome: str
email: str
class RepositorioDeClientes[T](Protocol):
def buscar(self, id: int) -> T | None: ...
def salvar(self, item: T) -> None: ...
class RepositorioEmMemoria:
def __init__(self) -> None:
self._dados: dict[int, Cliente] = {}
def buscar(self, id: int) -> Cliente | None:
return self._dados.get(id)
def salvar(self, item: Cliente) -> None:
self._dados[item["id"]] = item
def processar_cliente(repo: RepositorioDeClientes[Cliente], cliente: Cliente) -> None:
repo.salvar(cliente)
RepositorioEmMemoria nunca declara herdar de RepositorioDeClientes — mas, como tem os métodos certos com as assinaturas certas, o mypy aceita passá-lo para processar_cliente. E Cliente, como TypedDict, garante que ninguém vai tentar acessar cliente["nomee"] sem ser avisado antes mesmo de rodar o código.
mypy vs. pyright: qual usar?
Os dois fazem, essencialmente, a mesma coisa — analisar seu código estaticamente e apontar inconsistências de tipo antes da execução. As diferenças práticas:
mypyé o verificador "oficial" da comunidade Python, mais antigo, com configuração viamypy.inioupyproject.toml. É o mais usado em pipelines de CI.pyright, da Microsoft, é o motor por trás da checagem de tipos do VS Code (via extensão Pylance) — costuma ser mais rápido e dar feedback em tempo real enquanto você digita.
Uma prática comum e produtiva: usar pyright/Pylance no editor para feedback instantâneo durante o desenvolvimento, e mypy no pipeline de CI como porta de qualidade antes do merge. Os dois seguem a mesma especificação de tipos (definida nas PEPs), então o comportamento costuma ser consistente entre eles — divergências pontuais existem, mas são a exceção, não a regra.
Boas práticas
- Comece pequeno: não tente tipar 100% de um projeto legado de uma vez. Rode o
mypycom--strictsó nos módulos novos, e vá expandindo aos poucos. - Use
Protocolpara desacoplar, especialmente em pontos de integração (repositórios, clientes HTTP, adaptadores) onde você quer poder trocar a implementação sem alterar quem consome. - Use
TypedDictpara estruturas internas conhecidas — payloads de eventos internos, resultados intermediários de processamento — onde você não precisa (ou não quer pagar) o custo de validação em runtime. - Prefira Pydantic no lugar de
TypedDictsempre que o dado vier de fora do seu controle direto e precisar de validação de verdade, não só de documentação de tipo. - Adote a sintaxe da PEP 695 (
class Resultado[T]) se seu projeto já roda em Python 3.12+; é mais legível queTypeVarexplícito. - Rode o verificador de tipos no CI, não só localmente — é fácil ignorar um aviso do editor, mas um pipeline que falha por erro de tipo é uma trava real de qualidade.
Conclusão
Type hints "básicos" documentam a intenção. Protocol, TypedDict e Generic vão além: eles permitem que o verificador de tipos entenda relações reais do seu design — contratos de comportamento, estruturas fixas de dados, tipos que se propagam de forma consistente por uma função ou classe inteira.
O ganho não é estético. É pegar, em segundos, no seu editor ou no CI, o mesmo tipo de bug que — sem isso — só apareceria em produção, num KeyError ou AttributeError que ninguém viu vir. Não é sobre "deixar o código mais bonito com anotações". É sobre transformar o verificador de tipos num colega que revisa seu código antes mesmo de você rodar python main.py.