Imagine que você pede a alguém para construir uma estante. A pessoa entrega algo bonito, com prateleiras, parafusos, tudo no lugar. Você encosta na parede e ela desaba. Os parafusos são cenográficos. Parecem parafusos, mas são de plástico.

É isso que um LLM faz quando abusa do sistema de tipos. Ele te entrega código que compila, passa os testes, e parece correto. Porém, por dentro, onde deveriam haver tipos com significado, há strings. Onde deveria haver um estado explícito, há um nil que pode ter três significados diferentes, dependendo de quem lê. E onde deveria haver um enum com dois casos, há um == "claude" que alguém eventualmente escreverá errado, sem ninguém perceber até chegar em produção.

O atalho favorito do modelo: a string universal

Passei meses trabalhando com um agente de IA em um aplicativo Swift de porte médio. O código que ele gera é limpo, bem estruturado, com bons nomes. Mas há um padrão que se repete continuamente: o modelo evita criar novos tipos.

Não é por maldade. Ele faz isso porque criar um tipo novo requer tomar decisões de design: é um enum? Quantos casos ele possui? Onde ele vive? Quem o importa? Um String não exige nenhuma decisão. Ele serve em qualquer lugar. Sempre compila.

O resultado é um código como este:

func sessions(harness: String? = nil) -> [Session] {
    if let harness {
        return all.filter { $0.harness == harness }
    }
    return all
}

// Uso:
let claudeSessions = sessions(harness: "claude")
let codexSessions = sessions(harness: "codex")

Parece adequado. Funciona. Mas há três problemas escondidos:

  1. Ninguém te avisa se você escrever "cladue". Um string aceita qualquer coisa. Um enum não.
  2. nil significa “todos”, mas isso não está especificado em nenhum tipo. É uma convenção que só vive na sua cabeça e em um comentário que ninguém vai ler.
  3. Cada função que filtra por harness repete o mesmo padrão de String? = nil. Se amanhã você adicionar um terceiro harness, terá que procurar todas as comparações de strings espalhadas pelo código.

Usease: o compilador não pode te ajudar porque você tirou toda a informação semântica dele. Você deu um String onde existia um conceito próprio do domínio.

A versão que eu deveria ter escrito

enum HarnessID: String, Codable {
    case claude
    case codex
}

enum HarnessFilter {
    case all
    case specific(HarnessID)
}

func sessions(harness: HarnessFilter = .all) -> [Session] {
    switch harness {
    case .all:
        return all
    case .specific(let id):
        return all.filter { $0.harnessID == id }
    }
}

// Uso:
let claudeSessions = sessions(harness: .specific(.claude))
let allSessions = sessions()  // .all por padrão — explícito

Agora "cladue" não compila. “Todos” não é mais um nil mágico, mas sim um caso explícito do enum. E se você adicionar um terceiro harness, o compilador te obriga a lidar com o novo caso em cada switch. Ele transforma erros de runtime em erros de compilação. Isso é o que um sistema de tipos deveria fazer.

O nil como caixa de desastre

O segundo atalho favorito do modelo é usar nil para representar algo que tem um significado próprio. O modelo adora opcionais. E faz sentido: um campo opcional é a maneira mais rápida de adicionar um dado sem quebrar nada. Mas há uma diferença enorme entre “este valor pode não existir” e “este valor tem um estado específico que estou codificando como ausência”.

Exemplo real:

var calibrationDate: Date?  // nil = nunca calibrado
var quotaPercent: Double?   // nil = desconhecido
var errorMessage: String?   // nil = sem erro

Três campos, três significados diferentes para nil. O primeiro é legítimo: pode ser que nunca tenha sido calibrado. O segundo é questionável: “desconhecido” é um estado que merece seu próprio tipo. O terceiro é perigoso: você está usando a ausência de erro para representar sucesso, e a presença de um string para representar falha. Isso é um Result disfarçado de opcional.

O que o compilador vê nos três casos é igual: Optional<T>. Ele não distingue “ausente legitimamente” de “estou usando nil como uma flag booleana”. E se o compilador não vê isso, você também não verá quando revisitar o código daqui a seis meses.

Por que o LLM faz isso

Não é preguiça. É otimização com foco no objetivo errado.

O modelo foi treinado para gerar código que compila e passa testes. Um String sempre compila. Um nil sempre compila. Um enum novo exige definí-lo, importá-lo, e atualizar todos os call sites. Sob a perspectiva do modelo, o string tem menos atrito e o mesmo resultado imediato.

É exatamente como um iniciante que usa any no TypeScript. Não é que ele não saiba que existem tipos melhores. É que any compila e o ticket é fechado. O incentivo está desalinhado.

O que o modelo otimizaO que você precisa
Compilar de primeiraQue o compilador te dê segurança
Passar nos testes existentesQue erros novos sejam impossíveis
Mínima mudança no códigoMáxima informação semântica
Resolver o problema imediatoEvitar problemas futuros

Primeira defesa: um linter de tipos

Quando percebi que o modelo repetia esses padrões, minha reação inicial foi como sempre: escrever regras no CLAUDE.md. “NUNCA use String onde deveria ser um enum”. “NUNCA use nil como flag”.

Já sabemos como isso termina. O modelo lê a regra, concorda, e três gerações depois volta a usar harness: String? = nil. Não por rebeldia — porque o string é o caminho de menor resistência e o contexto de 200K tokens empurrou sua regra para fora da zona de atenção.

Então eu escrevi um linter. Um script bash que busca padrões suspeitos no código fonte:

# T4: Comparação literal contra valores que deveriam ser enum
fail_if_found "T4" "ERRO" \
    'Comparação == "claude" / == "codex" (deveria ser == .claude)' \
    '==\s*"(claude|codex)"'

# T7: nil como "todos" em parâmetros de filtro
fail_if_found "T7" "ERRO" \
    'Parâmetro harness: String? = nil (deveria ser HarnessFilter)' \
    'harness:\s*String\?\s*='

# T1: ExpressibleByStringLiteral em tipos de domínio
fail_if_found "T1" "ERRO" \
    'ExpressibleByStringLiteral reintroduz stringly-typed' \
    'ExpressibleByStringLiteral'

Oito verificações no total. Cada uma busca um padrão concreto com grep. Se encontrar, erro. Sem interpretação, sem julgamento, sem “bom, neste caso faz sentido”. Se o padrão aparecer, o CI falha.

Vai ser que não, modelo. Esse == "claude" não passa.

O linter é deliberadamente básico. Não analisa AST, não entende contexto, não usa heurísticas sofisticadas. Ele procura texto. E isso é uma vantagem: é impossível enganá-lo. O modelo não pode racionalizar uma exceção se a detecção for puramente textual.

Segunda defesa: uma skill de auditoria

O linter capta os sintomas. Mas os sintomas são consequência de um problema mais profundo: o modelo não pensa se o tipo que está usando é o correto.

Para isso eu criei uma skill de auditoria — um arquivo Markdown que o agente executa sob demanda para revisar sistematicamente o uso de tipos em qualquer módulo. A skill busca:

  1. Strings que representam conjuntos finitos. Se um campo tem apenas 3 valores possíveis, deveria ser um enum, não um String.
  2. Opcionais que representam estados. Se nil significa algo específico (“desconhecido”, “não aplicável”, “todos”), deveria ser um caso de enum.
  3. Dicionários com chaves string onde a chave é um conceito de domínio. [String: Session] deveria ser [HarnessID: Session].
  4. setValue(forKey:) e similares que burlam o sistema de tipos do Core Data.
  5. Dictionary(uniqueKeysWithValues:) sem garantia de que as chaves sejam únicas — isso gera erros em runtime.

A skill não é mágica. É uma checklist que força o modelo a analisar o código com um foco específico. A diferença entre “revise os tipos” (vago) e “procure campos String que só admitem N valores conhecidos” (específico) é a diferença entre uma auditoria útil e um “parece tudo bem”.

O antes e o depois

Depois de aplicar o linter e a auditoria a um módulo que estava em desenvolvimento há dois meses:

AchadoQuantidadeSeveridade
Comparações == "string literal"4Erro: typo invisível em runtime
Parâmetros String? = nil como filtro3Erro: nil semântico oculto
Campos String que eram enums disfarçados2Refatoração: perda de segurança de tipos
setValue(forKey:) no Core Data1Erro: o compilador não valida o nome do campo

Dez achados. Todos inseridos pelo modelo. Nenhum detectado pelos testes existentes, porque os testes usavam as mesmas strings que o código. Ficção validando ficção novamente.

Depois da refatoração, o código tinha 40 linhas a mais (as definições dos enums) e zero strings soltas. Cada comparação passava pelo compilador. Cada filtro tinha um tipo explícito. E o próximo bug de “o modelo escreveu "cladue" sem querer” tornou-se impossível.

O que você pode fazer hoje no seu projeto

Você não precisa de Swift. Não precisa do meu linter. O padrão se aplica a qualquer linguagem com sistema de tipos:

Em TypeScript:

// Antes: stringly-typed
function getUsers(role: string = "all") { ... }
getUsers("adnin")  // typo, compila, falha em runtime

// Depois: type-safe
type Role = "admin" | "viewer" | "billing"
type RoleFilter = Role | "all"
function getUsers(role: RoleFilter = "all") { ... }
getUsers("adnin")  // erro de compilação

Em Python com Enum:

# Antes
def process(status: str | None = None): ...
process("pending")  # "pending", "Pending" ou "PENDING"?

# Depois
class Status(Enum):
    PENDING = "pending"
    DONE = "done"

def process(status: Status | None = None): ...

A regra geral: se um valor só pode assumir N opções conhecidas, ele deveria ser um tipo com N casos, não uma string com possibilidades infinitas. Se nil significa algo diferente de “ausente”, ele merece um nome próprio.

E se você trabalha com um agente IA, monte um linter que procure padrões suspeitos. Não precisa ser sofisticado. Um grep com os padrões do seu domínio basta. O importante é que ele rode no CI, seja automático e não dependa de o modelo ter lido seu README.

O meta-problema

Há algo irônico nisso tudo. Estamos pedindo a um modelo de linguagem — um sistema que opera fundamentalmente com strings — que pare de usar strings onde não deveria.

É como pedir a um carpinteiro que pare de usar madeira. É claro que ele consegue, mas seu instinto o leva lá. A string é o tipo nativo do LLM. Tudo o que ele vê é texto. Tudo o que ele gera é texto. Quando precisa decidir entre um tipo com semântica rica e uma string que “funciona”, a string vence por padrão.

A solução não é pedir para ele mudar. É atacar o problema com ferramentas que detectem o padrão automaticamente e o bloqueiem antes que chegue ao main. Um linter básico supera um modelo sofisticado quando o linter sempre roda e o modelo falha ocasionalmente.

Seus tipos são sua documentação viva. Se você os degrada para strings, seu código compila, mas não comunica nada. E o próximo a lê-lo — seja você, um colega ou outro modelo — terá que adivinhar o significado daquele nil.

Torne o ato de adivinhar desnecessário. Crie um sistema onde o caminho errado não compila.