17 pontos por GN⁺ 2025-07-26 | 8 comentários | Compartilhar no WhatsApp
  • Ao programar, é possível usar o sistema de tipos para distinguir com clareza diferentes significados de dados
  • Usar diretamente tipos genéricos como string ou inteiro faz perder o contexto e pode levar a bugs
  • Mesmo com o mesmo tipo base, se você definir novos tipos de acordo com o propósito, é possível evitar erros em tempo de compilação
  • Na biblioteca Go libwx, são definidos tipos que distinguem claramente unidades de medida para evitar erros causados pela mistura de float64
  • No código de exemplo, o tipo UUID é separado em UserID e AccountID, e o compilador bloqueia usos incorretos
  • Mesmo em linguagens como Go, cujo sistema de tipos não é tão forte, um simples encapsulamento de tipos pode prevenir bugs

Vamos usar ativamente o sistema de tipos

Ponto de partida do problema: mistura de tipos simples

  • Em programação, é comum representar muitos valores usando apenas tipos básicos como string, int e UUID
  • Porém, à medida que o projeto cresce, tornam-se frequentes erros em que esses tipos simples são misturados e usados sem distinção
    • Ex.: passar por engano uma string de userID como accountID, ou inverter a ordem em uma função com 3 parâmetros do tipo int

Solução: definir tipos que revelem a intenção

  • int e string são apenas blocos de construção; se forem passados diretamente por todo o sistema, o contexto significativo se perde
  • Para evitar isso, é preciso definir e usar tipos próprios para cada papel
    • Exemplo:
      type AccountID uuid.UUID  
      type UserID uuid.UUID  
      
      func UUIDTypeMixup() {  
          {  
              userID := UserID(uuid.New())  
              DeleteUser(userID)  
              // sem erro  
          }  
      
          {  
              accountID := AccountID(uuid.New())  
              DeleteUser(accountID)  
              // erro: não é possível usar AccountID como UserID  
          }  
      
          {  
              accountID := uuid.New()  
              DeleteUserUntyped(accountID)  
              // sem erro em tempo de compilação; alta chance de problema em tempo de execução  
          }  
      }  
      
  • Assim, é possível barrar argumentos com tipo incorreto em tempo de compilação

Caso real de aplicação: a biblioteca libwx

  • O autor aplica essa técnica em sua biblioteca Go libwx
  • Para todas as unidades de medida, ele define tipos dedicados e também associa métodos de conversão de unidade aos próprios tipos
    • Ex.: o método Km.Miles() deixa a distinção de unidades explícita
  • Abaixo está um exemplo em que o compilador bloqueia ordem errada de argumentos e confusão entre unidades:
    // declaração de temperatura em Fahrenheit  
    temp := libwx.TempF(84)  
    
    // declaração de umidade relativa (porcentagem)  
    humidity := libwx.RelHumidity(67)  
    
    // passado incorretamente para uma função que exige temperatura em Celsius, não Fahrenheit  
    fmt.Printf("Dew point: %.1fºF\n",  
      libwx.DewPointC(temp, humidity))  
    // o compilador detecta imediatamente o erro de incompatibilidade de tipos  
    // temp (tipo TempF) não pode ser usado como TempC  
    
    // ordem dos argumentos passada incorretamente para a função  
    fmt.Printf("Dew point: %.1fºF\n",  
      libwx.DewPointF(humidity, temp))  
    // o compilador impede o erro de tipos nos argumentos  
    
  • Isso permite prevenir todos os erros que poderiam surgir se tudo fosse apenas float64

Conclusão: use ativamente o sistema de tipos

  • O sistema de tipos não serve apenas para verificar sintaxe, mas também é uma ferramenta de prevenção de bugs
  • Vale definir tipos de ID separados para cada modelo e também encapsular argumentos de função em tipos claros em vez de usar float ou int diretamente
  • Essa abordagem é muito eficaz e simples de implementar, mesmo em linguagens como Go, cujo sistema de tipos não é tão forte
  • Na prática, há muitos bugs causados pela mistura de UUIDs ou strings
  • O autor ressalta que é surpreendente que esse método simples ainda não seja amplamente usado em código de produção

Código relacionado

8 comentários

 
vk8520 2025-07-29

Pelo que sei, ao tentar usar isso em Kotlin, tipos primitivos acabam sendo encapsulados em wrappers, o que pode causar um problema de desempenho porque são armazenados no heap em vez da stack. Claro que, na maioria dos casos de uso, a manutenibilidade vem em primeiro lugar. Além disso, é possível minimizar o problema de desempenho usando value class.

 
regentag 2025-07-28

A linguagem Ada tem, nesse aspecto, um sistema de tipos excelente. Valores de naturezas diferentes podem ser facilmente declarados como tipos distintos e, quando misturados, o compilador os detecta muito bem.

 
roxie 2025-07-28

Pergunto por curiosidade. Há também alguma vantagem diferente em relação a outras linguagens de tipos populares? (kotlin, rust, typescript, ...)

 
regentag 2025-07-28

As vantagens de Ada, em geral, são mais na linha de "é melhor que C". Em C, a confiança no desenvolvedor e a quantidade de coisas permitidas sem restrição são grandes. Coisas como conversões implícitas de tipo, por exemplo. Mas a maioria dos desenvolvedores parece preferir mais C, talvez por estarem acostumados com ela...

Pode ser uma característica da base de código em que trabalho, mas declaramos e usamos tipos separados para quase tudo. Usar tipos básicos fica praticamente só para índice de array.

 
roxie 2025-07-28

Entendi, obrigado.

 
GN⁺ 2025-07-26
Comentários do Hacker News
  • Eu gosto dessa abordagem, a de "tornar estados ruins impossíveis de representar", mas um problema comum nesse padrão é que os desenvolvedores param só na primeira etapa da implementação de tipos. Tudo vira tipo, eles não se encaixam bem entre si, surgem muitos tipos levemente modificados, e o código fica difícil de rastrear e entender. Numa situação assim, eu preferiria escrever numa linguagem dinâmica com tipagem fraca (JS) ou dinâmica com tipagem forte (Elixir). Mas, se os desenvolvedores continuam levando adiante o fluxo guiado por tipos, empurrando a lógica condicional para union types compatíveis com pattern matching e aproveitando bem delegação, a experiência de desenvolvimento volta a ficar agradável. Por exemplo, dá para fazer uma função DewPoint aceitar vários tipos e ainda funcionar de forma natural.

    • Por isso, eu queria que mais linguagens tivessem suporte nativo a tipos com intervalo limitado. Por exemplo, em vez de x: u32, seria ótimo poder fazer o sistema de tipos impor que x só aceite o intervalo [0,10). Isso eliminaria a necessidade de bound checks em indexação de arrays. Em casos como Option, peephole optimization também ficaria muito mais fácil. Em Rust, graças ao LLVM, existe algum suporte desse tipo dentro da função, mas ele não se mantém quando a variável é passada entre funções.

    • Só para constar, Ruby não tem tipagem fraca, e sim tipagem forte. Se você fizer uma operação como 1 + "1", vai receber um erro como TypeError: String can't be coerced into Integer.

    • "Parar na primeira etapa da implementação de tipos" é a causa do fracasso. Por exemplo, começar a usar int embrulhado em struct como UUID é um bom início, mas alguém pode simplesmente pegar qualquer int, empacotar no tipo e passar adiante, quebrando a propriedade de unicidade que o UUID deveria ter. No fim, o importante é ser "Correct by construction". Um tipo que precisa ser único, como UUID, não deveria poder ser criado a menos que isso seja realmente provado de alguma forma, seja lançando exceção em função/construtor, seja por outro mecanismo. Esse conceito vale não só para UUID, mas para qualquer tipo e qualquer invariante.

    • Ultimamente tenho seguido o padrão Red-Green-Refactor, mas em vez de depender de um teste falhando, torno o sistema de tipos mais rígido para que o bug seja pego pelo type checker. Novas funcionalidades, edge cases e bugs que não podem ser induzidos via tipos eu ainda trato com testes, mas usar o sistema de tipos nesse red-green-refactor costuma ser mais rápido e pode bloquear por completo grandes categorias de bugs.

    • Structural types conseguem aliviar a maioria desses problemas. E, quando realmente necessário, dá para impor isso com nominal types.

  • Numa linha próxima de exceções e tipos, acho bom usar checked exceptions de forma apropriada para cada tipo. Não entendo por que checked exceptions do Java recebem tanta crítica. Em um projeto de que participei, eu forcei o uso delas; no começo todo mundo odiou, mas depois que se acostumaram a pensar em todos os casos excepcionais do fluxo do código, passaram a gostar. Não fui tão rígido em testes unitários, mas o projeto ficou muito robusto.

    • A reclamação sobre checked exceptions no Java é que tratar exceções é trabalhoso demais. Quem escreve biblioteca não consegue decidir claramente quais checked exceptions expor, e quem está do lado cliente acaba tendo que escrever tratamento de exceção inútil a cada chamada de função, então é natural passar a odiar isso. Se fosse fácil converter exceções para outro tipo ou para runtime exception, ou só declará-las no nível de módulo/app, esse problema diminuiria, mas hoje é burocrático demais. Além disso, como é fácil quebrar a assinatura, você acaba precisando usar exceções específicas de domínio, e o Java também torna desconfortável fazer esse mapeamento. Checked exceptions são boas, mas a usabilidade do tratamento de exceções em Java é ruim.

    • Checked exceptions passaram a ser criticadas por causa do abuso. O fato de Java suportar tanto checked quanto unchecked exceptions foi uma boa escolha. Mas o ideal é usar checked exceptions só para casos como as exceções "exogenous" mencionadas por Eric Lippert, e transformar a maioria das demais em unchecked. Por exemplo, um banco de dados pode cair a qualquer momento, mas propagar throws SQLException por toda a call stack é burocrático demais. Dá para tratar tudo no topo com um catch-all e retornar HTTP 500. Texto relacionado

    • Checked exceptions, em comparação com as unchecked, têm o problema de que, se uma função profunda na call stack passar a lançar uma exceção, talvez seja preciso modificar não só o handler, mas também todas as funções intermediárias. Ou seja, o sistema fica menos flexível para mudanças. A discussão sobre o coloring de funções async tem uma lógica parecida. Se algo pode lançar exceção, ou você envolve com try/catch, ou o chamador também precisa declarar que lança.

    • C# adotou tipos claros, mas exceções unchecked, e a pilha de erros fica limpa e sem problema algum. Isso é mais limpo do que exception handlers com pattern matching fazendo tratamento bespoke em cada nível. Se houver um erro de resultado com unwrapping robusto, acho parecido.

    • Em Java também há um problema de baixa usabilidade com tipos checked. Por exemplo, ao usar a API de streams, é realmente ruim quando uma função map/filter lança checked exception. Se várias chamadas de serviço têm cada uma sua própria checked exception, no fim você acaba preso entre capturar Exception ou manter uma lista absurda de exceções.

  • No geral, concordo com a ideia de "criar tipos distintos", mas já tive muitas experiências difíceis em sistemas onde tudo era um tipo distinto. Fica especialmente complicado quando código que só move bytes de um lado para outro se mistura com código de cálculo de domínio.

    • Entendo bem essa sensação. Você já tem os dados de que precisa, mas antes precisa descobrir como criar o tipo ou instância, então sem uma receita parece que você está brigando com a documentação. Por exemplo, você já tem um objeto {x, y, z}, mas precisa usar antes uma função createVector(x, y, z): Vector; e, para criar um Face, tem algo como createFace(vertices: Vector[]): Face, o que torna o processo desnecessariamente longo. Em algo como BouncyCastle, mesmo com um array de bytes pronto, você ainda precisa criar vários tipos e usar seus métodos antes de conseguir fazer o que realmente queria.

    • Em Go, é relativamente fácil voltar de um alias de tipo ao tipo original, por exemplo AccountID → int. Se a estrutura estiver bem feita, dá para seguir um estilo de clean architecture em que a lógica de domínio usa aliases de tipo e as bibliotecas que não ligam para o domínio trabalham convertendo para tipos mais altos/mais baixos, mas isso exige muito código de conversão.

    • Phantom types são úteis nesses casos. Você adiciona um parâmetro de tipo, ou seja, um genérico, mas esse parâmetro não é usado de fato em lugar nenhum. Já usei isso em Scala ao escrever código de criptografia: os arrays eram todos de bytes, mas os phantom types impediam que eles se misturassem. Caso relacionado

    • Idealmente, bastaria o compilador verificar os tipos e depois reduzir toda a lógica de domínio restante a simples cópia de bytes, embora eu não tenha certeza se entendi exatamente a sua intenção.

  • Acho que o sistema de tipos também segue a regra 80/20. Se for levado longe demais, usar bibliotecas fica oneroso e o ganho real quase desaparece. UUID ou String são familiares, mas AccountID e UserID não são, então você precisa aprender algo novo e isso tem custo. Um sistema de tipos elaborado pode ou não valer a pena, especialmente se os testes já forem suficientes. Referência relacionada

    • De todo modo, para usar o software você já precisa saber o que é Account ou User, então não acho que uma função como getAccountById recebendo AccountId seja mais difícil de entender do que uma que recebe UUID.

    • Na verdade, String é só um conjunto de bytes e não tem significado nenhum por si só. Já AccountID, na maioria dos casos, deixa claro que se trata do "ID da conta". Se você realmente quiser saber a representação interna, basta olhar a definição do tipo, mas na maior parte dos contextos só saber o que é AccountID já basta. No fim, tipos ficam menos confusos de usar quando têm nomes claros. O link do grugbrain.dev, inclusive, parece básico demais; um grug brain provavelmente aprovaria esse nível de separação de tipos.

    • foo(UUID, UUID) é muito pior do que foo(AccountId, UserId). A segunda forma é autoexplicativa e ainda permite que o compilador pegue erros se você inverter a ordem dos argumentos por acidente. Isso também deixa estruturas de dados complexas mais claras sem precisar inventar novos tipos.

      Map<UUID, List<UUID>>
      Map<AccountId, List<UserId>>
      
    • Sobre a ideia de que "UUID ou String já são familiares": na prática, é difícil saber exatamente como UUID é armazenado ou convertido, seja GUIDv1, UUIDv4, UUIDv7 etc. Já tive, por experiência própria, que mexer manualmente em problemas de endianness na conversão entre UUID e uniqueidentifier numa combinação Java + MS SQL. Suspeito que seja parecido com os problemas de conversão automática de fuso horário em bancos de dados.

    • Na prática, você já precisava entender esses tipos de qualquer jeito; caso contrário, teria acabado passando dados errados para a função.

  • Recentemente, nossa equipe também aplicou tipos em várias partes de um código C++ onde diferentes valores numéricos estavam sendo misturados. Começou quando fomos corrigir um bug e introduzimos tipos seguros; aí descobrimos mais três lugares com usos incorretos parecidos.

  • A biblioteca mp-units(documentação oficial do mp-units) me lembra um bom exemplo focado justamente nesse problema de unidades físicas. Com tipos fortes para unidades, você ganha segurança, automatiza lógica complexa de conversão de unidades e ainda pode tratar várias unidades com código genérico. Tentei levar isso para o mundo Prolog, mas os colegas ao redor não se empolgaram tanto. Exemplo para Prolog

    • Já trabalhei em um projeto que lidava com várias grandezas físicas — distância, velocidade, temperatura, pressão etc. — e tudo era passado simplesmente como float, então você podia colocar uma distância no lugar de uma velocidade sem qualquer erro de compilação, e o bug só aparecia em runtime. O mesmo valia para erros de unidade, como passar km/h em vez de miles/h. Eu queria aumentar os tipos para capturar esses problemas durante o desenvolvimento, mas na época eu era júnior e era difícil convencer os outros.

    • Eu tinha desistido de aplicar tipos por unidade física por medo de ficar complexo demais, mas agora pretendo olhar o mp-units. O fato de muitas variáveis não indicarem claramente em que unidade estão costuma causar problemas, e isso é comum também em dados externos ou funções padrão.

  • Em C#, você pode criar tipos assim:

    readonly struct Id32<M> {
      public readonly int Value { get; }
    }
    

    Então:

    public sealed class MFoo { }
    public sealed class MBar { }
    Id32<MFoo> x;
    Id32<MBar> y;
    

    Desse jeito, dá para distinguir diferentes IDs inteiros. Também dá para expandir para IdGuid ou IdString, e adicionar um novo marker type (M) exige só uma linha. Já usei variações parecidas em TypeScript e Rust.

    • Já usei um padrão parecido, e para IDs int o enum tem a menor friction, mas achei que ficaria confuso demais e acabei não colocando em código real. Discussão relacionada

    • Esse padrão é chamado de "phantom type", porque os valores de MFoo ou MBar não existem em runtime.

    • Existem bibliotecas para isso também, como o Vogen. Vogen significa Value Object Generator, e usa geração de código-fonte para facilitar a adição de tipos de value object. No README há links para bibliotecas parecidas.

  • Eu já tinha visto essa abordagem antes, mas não entendia o propósito. Hoje mesmo, escrevendo uma função que recebia três argumentos string, fiquei pensando se deveria forçar o parsing do tipo antes da chamada ou fazer isso dentro da função. Como no fim eu nem precisava do valor parseado, esse método era exatamente a resposta que eu procurava. Acho que vai ser a maior influência no meu estilo de programação este ano.

  • Meu amigo Lukas organizou essa ideia sob o nome "Safety Through Incompatibility". Eu apliquei esse padrão por todo o meu código Go e achei extremamente útil. Ele bloqueia pela raiz o envio de IDs errados.
    Texto relacionado 1
    Texto relacionado 2

  • Em Swift existe a palavra-chave typealias, mas, se o tipo base for o mesmo, eles continuam conversíveis livremente, então isso não atende de verdade a esse objetivo. Um wrapper struct funciona de forma idiomática em Swift, e com ExpressibleByStringLiteral até fica razoavelmente prático. Ainda assim, seria bom ter uma nova palavra-chave como "strong typealias" (typecopy, algo assim) para indicar claramente: "isto é só uma String, mas é uma String com significado especial, então não misture com outras Strings".

    • Na prática, a maioria das linguagens é assim. Rust/C/C++ também, por exemplo, e é agradável quando você não precisa criar um wrapper type como no exemplo de Go. Em C++, se você não marcar o construtor como explicit, ainda precisa ter muito cuidado, porque um int pode entrar livremente no lugar de um tipo Foo.

    • Na teoria isso parece elegante, mas aplicar na prática pode ser complicado. Em C++, por exemplo, surge a questão de compatibilidade com std::cout, com funções de terceiros que antes recebiam String, ou com extension points já existentes.

    • Haskell tem esse conceito com newtype. Em linguagens OO, se o tipo não for final, fica fácil criar subclasses e adicionar ou especializar comportamentos. É barato e simples, sem wrapper extra ou boxing. Mas em Java isso é difícil porque String é final, então especializar a própria String não é simples.

    • Fiquei curioso sobre como exatamente você gostaria que isso funcionasse de modo diferente de um wrapper struct.

 
brain1401 2025-07-28

O Rust também é usado desse jeito, né? Com certeza parece algo muito bom.

 
regentag 2025-07-28

Se você usar uma linguagem com um sistema de tipos bem estruturado, talvez desse para evitar esse tipo de coisa também...
Desaparecimento da Mars Climate Orbiter da NASA em setembro de 1999

  • A sonda foi controlada de forma incorreta e caiu devido a um problema de integração de dados entre um módulo que usava a unidade libra para expressar a magnitude da força e outro que usava a unidade newton.