Em muitos sistemas, valores diferentes compartilham a mesma representação em runtime. Um ID de usuário e um ID de pedido podem ser inteiros; um e-mail e um código de país podem ser strings. Mesmo assim, trocar esses valores acidentalmente é um erro de domínio. typing.NewType cria tipos estáticos distintos sobre uma representação existente, sem exigir uma classe completa para cada conceito.
Neste guia, você aprenderá a criar IDs semânticos, validar valores em funções de construção, trabalhar com bancos de dados, JSON, dataclasses e APIs, comparar NewType com aliases, subclasses e classes de valor, e reconhecer seus limites em runtime.
O problema dos tipos primitivos
def carregar_usuario(usuario_id: int) -> str:
...
def carregar_pedido(pedido_id: int) -> str:
...
usuario_id = 10
pedido_id = 20
carregar_usuario(pedido_id) # passa no analisadorOs dois parâmetros são inteiros, então o verificador não consegue distinguir seus significados.
Criando tipos com NewType
from typing import NewType
UsuarioId = NewType("UsuarioId", int)
PedidoId = NewType("PedidoId", int)
def carregar_usuario(usuario_id: UsuarioId) -> str:
...
def carregar_pedido(pedido_id: PedidoId) -> str:
...Agora, passar PedidoId onde UsuarioId é esperado gera um erro estático.
usuario_id = UsuarioId(10)
pedido_id = PedidoId(20)
carregar_usuario(usuario_id) # correto
carregar_usuario(pedido_id) # erro de tipagemComportamento em runtime
NewType não cria um wrapper de dados tradicional. Chamar UsuarioId(10) devolve o próprio inteiro em runtime.
valor = UsuarioId(10)
print(valor) # 10
print(type(valor)) # intA distinção existe principalmente para o analisador. Isso mantém interoperabilidade e baixo custo, mas significa que NewType não impõe validação sozinho.
NewType não é uma classe para isinstance
isinstance(valor, UsuarioId) # inadequadoO objeto criado por NewType não deve ser usado como classe de runtime em isinstance() ou issubclass(). Para verificações reais, teste o tipo base ou use uma classe de valor.
NewType versus alias
UsuarioId = intIsso é apenas um alias. UsuarioId e int são o mesmo tipo para o analisador. Com NewType, UsuarioId é distinto de int na direção de entrada: uma função que exige UsuarioId não deve aceitar um int arbitrário sem construção explícita.
Relação com o tipo base
Um valor NewType pode ser usado onde o tipo base é aceito.
def duplicar(valor: int) -> int:
return valor * 2
usuario_id = UsuarioId(10)
resultado = duplicar(usuario_id)UsuarioId é tratado como subtipo estático de int. A relação inversa não é automática: um int comum não é UsuarioId.
Construção não significa validação
usuario_id = UsuarioId(-10)NewType não verifica se o número é positivo. A chamada apenas marca o valor. Para invariantes, crie uma função de parsing ou construção.
def criar_usuario_id(valor: int) -> UsuarioId:
if valor <= 0:
raise ValueError("ID deve ser positivo")
return UsuarioId(valor)Centralizar a construção reduz marcações incorretas espalhadas pelo código.
Parsing de strings
def parse_usuario_id(texto: str) -> UsuarioId:
try:
valor = int(texto)
except ValueError as erro:
raise ValueError("ID inválido") from erro
return criar_usuario_id(valor)O parser converte e valida dados externos antes de devolver o tipo semântico.
IDs em dataclasses
from dataclasses import dataclass
@dataclass(frozen=True)
class Usuario:
id: UsuarioId
nome: str
@dataclass(frozen=True)
class Pedido:
id: PedidoId
usuario_id: UsuarioIdOs campos documentam o domínio e impedem associações acidentais durante construção e refatoração.
Repositórios tipados
class RepositorioUsuarios:
def obter(self, id_: UsuarioId) -> Usuario | None:
...
def excluir(self, id_: UsuarioId) -> None:
...O contrato deixa claro qual identificador cada repositório aceita.
Banco de dados
Drivers de banco devolvem tipos básicos. A camada de persistência deve converter explicitamente.
def usuario_da_linha(linha: tuple[int, str]) -> Usuario:
id_bruto, nome = linha
return Usuario(id=UsuarioId(id_bruto), nome=nome)Se o banco não garante a invariante, use a função validada criar_usuario_id().
JSON e APIs
Serialização geralmente usa o tipo base.
def usuario_para_json(usuario: Usuario) -> dict[str, object]:
return {
"id": int(usuario.id),
"nome": usuario.nome,
}Como o valor já é um int em runtime, a conversão pode ser opcional, mas torná-la explícita melhora a leitura na fronteira.
NewType com strings
Email = NewType("Email", str)
CodigoPais = NewType("CodigoPais", str)
def enviar(email: Email, mensagem: str) -> None:
...Uma função de construção pode normalizar e validar o e-mail antes de retornar Email.
def criar_email(valor: str) -> Email:
normalizado = valor.strip().casefold()
if "@" not in normalizado:
raise ValueError("e-mail inválido")
return Email(normalizado)Valores financeiros
NewType pode separar centavos, pontos ou quantidades, mas não adiciona operações seguras, arredondamento ou moeda.
Centavos = NewType("Centavos", int)
Pontos = NewType("Pontos", int)Para dinheiro com regras complexas, uma dataclass ou classe de valor pode ser melhor. NewType é adequado quando a representação básica já possui todas as operações necessárias e a principal necessidade é evitar mistura.
NewType e coleções
usuarios: list[UsuarioId] = [UsuarioId(1), UsuarioId(2)]
pedidos: list[PedidoId] = [PedidoId(10)]O analisador impede combinar coleções semanticamente diferentes, embora ambas contenham inteiros em runtime.
Dicionários e mapas
nomes: dict[UsuarioId, str] = {
UsuarioId(1): "Ana",
}
pedido_por_usuario: dict[UsuarioId, list[PedidoId]] = {}As chaves e valores mostram as relações do domínio de forma mais precisa.
Retornos de funções
def criar_usuario(nome: str) -> UsuarioId:
id_bruto = inserir_no_banco(nome)
return UsuarioId(id_bruto)O chamador recebe um identificador já classificado e não precisa adivinhar o significado do inteiro.
NewType e Optional
def encontrar_usuario(email: Email) -> UsuarioId | None:
...A união mantém a distinção semântica. Depois de tratar None, o valor restante é UsuarioId.
NewType aninhado
É possível criar um NewType baseado em outro, mas camadas excessivas podem confundir.
Id = NewType("Id", int)
UsuarioId = NewType("UsuarioId", Id)Use essa hierarquia somente se a relação de subtipo for útil. Na maioria dos projetos, NewTypes diretamente baseados em int ou str são mais simples.
NewType versus subclasse de int
class UsuarioIdRuntime(int):
passUma subclasse real existe em runtime e pode ser usada com isinstance(). Porém, construção, serialização, operadores e resultados de operações podem exigir cuidado. NewType é mais leve quando a distinção é apenas estática.
NewType versus dataclass de valor
@dataclass(frozen=True)
class UsuarioIdValor:
valor: int
def __post_init__(self) -> None:
if self.valor <= 0:
raise ValueError("ID inválido")A dataclass impõe invariantes, possui identidade de runtime e pode oferecer métodos, mas exige acesso a .valor e conversões. Escolha conforme a necessidade de comportamento.
NewType versus Annotated
Annotated adiciona metadados a um tipo, mas normalmente não torna duas anotações distintas para o analisador.
from typing import Annotated
UsuarioIdDoc = Annotated[int, "usuario"]Use NewType para distinção estática. Use Annotated quando frameworks, validadores ou documentação precisam de metadados.
NewType em bibliotecas públicas
Exportar NewTypes melhora contratos, mas pode ser uma mudança incompatível para usuários que passavam primitivos diretamente. Planeje migrações, ofereça factories e documente fronteiras onde a construção é esperada.
Operações aritméticas
usuario_id = UsuarioId(10)
proximo = usuario_id + 1O resultado de uma operação do tipo base costuma ser int, não UsuarioId. Isso é desejável: somar um identificador pode não produzir automaticamente outro identificador válido. Construa novamente apenas quando a operação fizer sentido no domínio.
Evite marcar valores arbitrários
def funcao_perigosa(valor: int) -> UsuarioId:
return UsuarioId(valor)Se qualquer parte do sistema puder converter qualquer int sem validação, a proteção perde valor. Restrinja a construção a parsers, repositórios e factories confiáveis.
Erros comuns
- Usar alias em vez de NewType: não cria distinção estática.
- Esperar validação automática: NewType devolve o valor base.
- Usar isinstance com NewType: ele não é uma classe de domínio em runtime.
- Marcar dados externos sem validar: a anotação não prova a invariante.
- Esperar que operações preservem o NewType: resultados normalmente voltam ao tipo base.
- Criar dezenas de tipos sem benefício: use onde erros de mistura são plausíveis e relevantes.
Exemplo completo: serviço de transferências
from dataclasses import dataclass
from typing import NewType
ContaId = NewType("ContaId", int)
Centavos = NewType("Centavos", int)
@dataclass(frozen=True)
class Transferencia:
origem: ContaId
destino: ContaId
valor: Centavos
def criar_conta_id(valor: int) -> ContaId:
if valor <= 0:
raise ValueError("conta inválida")
return ContaId(valor)
def criar_centavos(valor: int) -> Centavos:
if valor <= 0:
raise ValueError("valor deve ser positivo")
return Centavos(valor)
def transferir(
origem: ContaId,
destino: ContaId,
valor: Centavos,
) -> Transferencia:
if origem == destino:
raise ValueError("contas devem ser diferentes")
registrar_transferencia(origem, destino, valor)
return Transferencia(origem, destino, valor)O analisador impede passar Centavos como ContaId ou misturar identificadores com valores. As factories cuidam das regras de runtime.
Testando a tipagem
Inclua testes estáticos com chamadas inválidas esperadas. Use reveal_type() para confirmar resultados de operações. Testes de runtime devem focar nas factories, pois é nelas que as invariantes são realmente verificadas.
Conclusão
typing.NewType cria distinções semânticas leves sobre int, str e outros tipos existentes. Ele é ideal para IDs, códigos, unidades e valores que compartilham representação, mas não devem ser misturados.
A documentação oficial de NewType no Python detalha a semântica. Combine NewType com factories validadas nas fronteiras e escolha classes de valor quando precisar de comportamento, invariantes fortes ou identidade real em runtime.







