1 pontos por GN⁺ 4 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • Quando verificações como if (user.email) ficam espalhadas pelo código TypeScript, o fato já verificado não permanece no tipo, então partes mais profundas da pilha de chamadas continuam desconfiando da mesma condição
  • Um parser recebe entrada bruta e devolve um tipo mais restrito ou informações de falha, permitindo que o restante do programa confie em fatos já validados, como EmailAddress
  • Em TypeScript, que usa um sistema de tipos estrutural, string e Email não se separam naturalmente, então recorre-se a tipos branded baseados em unique symbol e a asserções as restritas para imitar fronteiras nominais
  • Uma união discriminada como Parsed<T> expõe sucesso e falha na assinatura de tipo, mas como não existe uma expressão match dedicada, é preciso escrever manualmente uma verificação exaustiva usando never
  • Zod, io-ts e valibot conseguem gerar juntos o parser e os tipos TypeScript a partir de um schema, mas a disciplina de fazer parsing em cada fronteira antes de tratar entradas externas como tipos de domínio continua sendo responsabilidade do desenvolvedor

Validação descarta informação; parsing a preserva no tipo

  • O princípio Parse, don’t validate de Alexis King coloca no centro a diferença entre validador e parser
    • Um validador decide “esse valor está ok” e depois deixa o fluxo seguir com um boolean ou uma exceção
    • Um parser recebe entrada bruta e produz um tipo mais preciso ou devolve o motivo da falha
  • Se os tipos continuam amplos, como User.email: string e User.age: number, mesmo que isValidUser(user): boolean retorne sucesso, o TypeScript não se lembra desse fato
  • Depois, em código como emailService.send(user.email, ...), user.email continua sendo apenas uma string comum, que pode ser uma string vazia, "hello" ou "definitely not an email"
  • Esse fluxo de revalidar a mesma condição em vários lugares se aproxima do que King chama de shotgun parsing

Uma API em que o próprio tipo é a prova

  • A forma desejada é uma assinatura de função como sendWelcome(user: ValidUser), que só aceite valores já parseados
  • Nessa estrutura, é obrigatório passar pelo parser antes de chamar sendWelcome, e não há necessidade de revalidação separada nem de if defensivos dentro da função
  • Em Elm, isso pode ser feito de forma simples com opaque types e smart constructors, mas em TypeScript são necessários mais mecanismos para obter o mesmo efeito

Criando fronteiras nominais com tipos branded

  • TypeScript usa um sistema de tipos estrutural, então tipos com o mesmo shape são tratados como iguais
    • string é string, e não existe um recurso que crie um tipo realmente diferente, como o newtype de Haskell
  • O desvio usado pela comunidade é o branding ou tagging
    • Uma forma simples é um campo phantom com literal de string, como { readonly __brand: "Email" }
    • Uma forma mais forte é usar como chave da marca um unique symbol que não é exportado para fora do módulo
  • Os tipos de exemplo têm a forma type Email = string & { readonly [EmailBrand]: true } e type Age = number & { readonly [AgeBrand]: true }
  • O campo da marca é um marcador em nível de tipo, sem existência em runtime, e faz Email e string serem tratados de forma diferente em tempo de compilação
  • A marca funciona apenas em uma direção
    • Email pode ser atribuído a string
    • Uma string comum não pode entrar diretamente como Email

O parser só permite asserções na fronteira de confiança

  • parseEmail(raw: string): Parsed<Email> devolve falha se a string não tiver @; se passar, cria o tipo branded com raw as Email
  • A asserção as Email é uma exceção aceitável porque o parser é a fronteira de confiança
    • Se outra parte do código fizer asserção de string para Email, o desenho se rompe
    • É possível colocar os parsers em um módulo separado e tratar qualquer asserção de marca fora dele como bug
  • No exemplo, Parsed<T> tem a forma { kind: "ok"; value: T } | { kind: "err"; error: ParseError }
    • A falha não fica escondida em uma exceção; ela aparece na assinatura de tipo
    • Usar discriminadores em string como kind: "ok" | "err" faz o narrowing funcionar de forma mais honesta quando novas variantes são adicionadas depois
  • O exemplo de parseEmail é propositalmente simples; um parser real de email também precisaria lidar com trim, lowercase, validação de domínio e mais

Separando entrada bruta e tipos de domínio confiáveis

  • Separar UnvalidatedUser de ValidUser deixa clara a diferença entre valores vindos da rede ou de outra entrada externa e valores confiáveis dentro do domínio
    • UnvalidatedUser mantém id, email e age como unknown
    • ValidUser usa tipos branded como UserId, Email e Age
  • Fazer branding também em UserId ajuda a evitar erros como passar um OrderId onde se espera um UserId
  • parseUser(raw: unknown): Parsed<ValidUser> vai restringindo a entrada bruta passo a passo
    • Verifica se a entrada é um objeto
    • Verifica a existência dos campos id, email e age
    • Verifica se email é uma string
    • Chama parseUserId, parseEmail e parseAge separadamente e retorna imediatamente em caso de falha
    • Se tudo der certo, retorna ValidUser
  • Esse estilo é mais verboso do que em F# ou Elm, mas faz com que sendWelcome(user: ValidUser) seja realmente seguro

Pontos em que TypeScript incomoda

  • O primeiro atrito é a asserção as Email dentro do parser
    • Em uma linguagem com tipos nominais de verdade, um smart constructor pode retornar o novo tipo sem precisar “mentir”
    • Como a marca em TypeScript é um marcador de tipo virtual, o parser precisa atravessar essa fronteira com uma asserção
  • O segundo atrito é a verificação exaustiva
    • Uniões discriminadas em TypeScript são poderosas nesse estilo, mas não existe uma expressão match dedicada
    • É preciso escrever manualmente um padrão como const _exhaustive: never = result no default do switch
    • Se uma terceira variante for adicionada a Parsed, a atribuição a never falha e o compilador aponta o local
  • satisfies pode servir como uma escape hatch mais elegante do que cast
    • const x = { ... } satisfies Config verifica o tipo sem ampliar desnecessariamente os tipos literais
  • JSON.parse retorna any, então é mais seguro anotá-lo imediatamente como unknown
    • Algo como const raw: unknown = JSON.parse(input), deixando o parser decidir depois se aquilo pertence ao tipo de domínio
    • JSON.parse não é um validador; é uma etapa de desserialização que transforma bytes em valores JS

Bibliotecas como Zod reduzem repetição

  • Zod, io-ts e valibot oferecem o mesmo padrão de um jeito mais conveniente do que escrever os parsers à mão
  • O exemplo com Zod cria juntos o parser e o tipo TypeScript a partir de um único schema
    • z.object({ id: z.number().int(), email: z.string().email().brand<"Email">(), age: z.number().int().min(0).max(150).brand<"Age">() })
    • z.infer<typeof ValidUserSchema> obtém o tipo
    • ValidUserSchema.safeParse(rawInput) devolve data em caso de sucesso e error em caso de falha
  • O .brand() do Zod também é um recurso em nível de tipo, como uma marca manual com symbol, e não tem comportamento em runtime
  • A biblioteca prende parser e tipo na mesma definição e facilita manter as fronteiras, mas não impõe por si só a disciplina de usá-la em todas as fronteiras externas
  • Um User vindo da rede não é um User de domínio até passar por parsing, e é preciso resistir à tentação de contornar mensagens de erro com asserções de tipo

Carregar a prova no tipo, não na memória

  • O pequeno princípio aqui é: “deixe o sistema de tipos carregar a prova, e não a memória das pessoas”
  • Se você checa uma condição e não codifica o resultado no tipo, o código posterior passa a assumir facilmente que aquela validação já foi feita
  • Em TypeScript, esse princípio é implementado com apoio de três ferramentas
    • Tipos branded para imitar identidade nominal
    • Uniões discriminadas para explicitar sucesso e falha
    • Uma fronteira rígida entre unknown de entradas externas e tipos de domínio confiáveis
  • Nem sempre faz sentido transformar todo o código em um pipeline de parsing, mas quando o mesmo if defensivo se repete em vários arquivos, isso é um sinal de que a informação a ser validada não conseguiu entrar no tipo

1 comentários

 
GN⁺ 4 시간 전
Opiniões no Lobste.rs
  • Se JavaScript/TypeScript entra em conflito, técnica e ergonomicamente, com o estilo de código que você quer, fico pensando se não bastaria usar uma das muitas linguagens que compilam para JS
    Haskell, Elm e F# são mencionadas, e há muitas linguagens na linha que o autor parece preferir, como PureScript, js_of_ocaml, Reason, LunarML etc. O autor chegou a escrever um texto chamado Why TypeScript Won’t Save You, comparando mais com suas linguagens preferidas, e também mantém o https://learnelm.dev.
    Ou talvez a comparação em si seja o objetivo: mostrar que TypeScript muitas vezes não é suficiente e incentivar a adoção de outras toolchains ou ideias

    • Há restrições como bases de código existentes, proficiência específica da equipe em uma linguagem ou diretrizes da empresa, além de menos suporte, ferramentas e uma comunidade menor
      A maioria simplesmente não tem a opção ou o tempo de escolher outra linguagem
    • Normalmente deve ser porque já existe uma grande base de código TypeScript, ou porque usam uma biblioteca TypeScript que não existe em outras linguagens
  • No trabalho, gosto muito de tipos com marca (branded types), mas me incomoda bastante não poder criar um Array ou TypedArray que só possa ser indexado por números com marca
    TypedArray nem sequer permite armazenar números com marca — ou, mais precisamente, nem permite lê-los de volta assim. Mesmo que fosse necessário um conjunto separado de tipos, como IndexArray ou IndexTypedArray, eu queria muito que esse recurso existisse

    • Eu também gosto de tipos com marca, mas, conversando com outras pessoas, todo mundo acha que não compensa tanto pelo esforço envolvido
      Em um esquema de banco de dados bem complexo, se você usar tipos com marca para todos os IDs, o TypeScript consegue detectar quando você cria joins ou condições sem sentido. As assinaturas de função também ficam mais claras, e fica mais difícil cometer vários tipos de erro
    • Se você estiver disposto a mentir com força suficiente, dá para criar um Array que só possa ser indexado por números com marca
      Se quiser, também é possível fazer a mesma coisa com valores de TypedArray
    • No trabalho, usamos “enums inteligentes” e tipos de array customizados para poder escrever algo como TArray<Foo, MyEnum>. Mas isso é assunto de C++
      A biblioteca std do Zig tem um EnumArray implementado com comptime. Ela também oferece recursos mais amplos, como usar enums densos ou esparsos para indexação e calcular o indexador correto em tempo de compilação.
      Estou gostando cada vez mais desse tipo de tipagem precisa. Ela impede bastante que bugs lógicos entrem na base de código