TypeScript é surpreendentemente boa até para implementar compiladores
(matklad.github.io)- Tradicionalmente, a linguagem de implementação de ferramentas do tipo compilador se dividia entre OCaml e C++, mas em pequenos experimentos de linguagem o TypeScript pode ser uma opção leve, quase como uma linguagem da família ML
- Rust combina as vantagens de ML e C++, além de oferecer multithreading seguro, mas exige modelar o layout físico dos dados, o que pode tornar pequenos protótipos mais pesados do que o ideal
- O Deno permite começar rapidamente experimentos de linguagem em TypeScript graças a binário único, linting e formatação embutidos, ausência de etapa de compilação, task runner e modo watch
- O typechecker de exemplo combina AST genérica, tagged unions, visitor e transform bottom-up para converter
Expr<void>emExpr<Type>, inserindoTypeErrorcomo valor de tipo para reduzir erros em cascata - O TypeScript pode ser produtivo como ferramenta para pequenos hacks de linguagem graças ao autocompletar, ao sistema de tipos flexível e às características de runtime que permitem recorrer a abordagens dinâmicas quando necessário
O cenário tradicional na escolha da linguagem de implementação
- Ao criar ferramentas no estilo de compiladores, a escolha da linguagem de implementação costuma seguir dois grandes caminhos
- Trabalhos centrados na linguagem, como especificações formais ou linguagens de brinquedo para hobby, combinam bem com OCaml
- Exemplos incluem plzoo e o WebAssembly reference interpreter
- Para trabalhos mais voltados à implementação e que precisam estar prontos para produção, C++ é uma escolha frequente
- LLVM, clang, v8 e HotSpot são todos baseados em C++
- Rust foi influenciada diretamente por ML e C++, combina as vantagens das duas linguagens e ainda oferece forças próprias, como multithreading seguro
- Ainda assim, no espectro ela pende mais para o lado da prontidão para produção
- Um sistema de build que “simplesmente funciona” ajuda na prototipagem, mas vem acompanhado da complexidade extra de ter que modelar o layout físico dos dados
As vantagens do estilo com índices em Rust e o peso disso em código pequeno
- Um conselho comum ao criar compiladores em Rust é evitar ponteiros e usar índices
- Índices trazem várias vantagens em codebases grandes
- Side tables podem ficar dentro dos módulos relacionados, reduzindo o acoplamento
- Os índices podem ser
u32e incentivar um layout struct-of-arrays, favorável ao desempenho - Fica mais fácil serializar ou conectar a frameworks de compilação incremental, dando flexibilidade à estratégia de computação
- Mas em programação de pequena escala, a abordagem baseada em índices acaba sendo trabalhosa, e em experimentos por hobby esse custo pode ser fatal
- OCaml ainda passa uma sensação de antiguidade, e nesse contexto surge a ideia de avaliar se o TypeScript pode funcionar como uma alternativa equivalente ao papel do ML
Deno e TypeScript como ambiente para experimentação rápida
- deno foi usado como ambiente inicial
- Ele oferece uma experiência pronta para uso imediato com TypeScript
- Em OCaml, isso costuma ser um ponto doloroso; Rust é melhor que OCaml ou C++ nesse aspecto, mas Deno oferece uma experiência ainda mais simples que Rust
- A experiência de desenvolvimento com Deno combina bem com pequenos hacks de PLT
- É um binário único
- Traz linting e formatação embutidos
- Não há etapa separada de compilação
- Há task runner e modo watch integrados
- O próprio TypeScript oferece um sistema de tipos suficientemente flexível com baixa carga sintática
Padrões em TypeScript vistos em um pequeno typechecker de exemplo
- A AST começa com expressões que carregam informações de posição no arquivo
Locationcontémfile,lineecolumn- Em TypeScript, strings são apenas
stringe números são apenasnumber, então não é preciso se preocupar com distinções comousizeeu32
- As expressões separam posição e kind, e depois os dados associados são generalizados na forma
Expr<T>- Logo após o parsing, a expressão carrega dados
void - Depois do processamento do typechecker, a expressão carrega dados
Type - A função de inferência de tipos recebe
ast.Expr<void>e retornaast.Expr<Type>
- Logo após o parsing, a expressão carrega dados
- Como o TypeScript não adiciona automaticamente comportamento de runtime, é preciso inserir manualmente informação de tipo em runtime para casar union types
- Campos como
tag: "binary"etag: "if"cumprem esse papel tag: "binary"significa que, em runtime, esse valor só pode ser a string"binary"
- Campos como
- Literais booleanos e literais inteiros têm praticamente a mesma forma, então são abstraídos como
ExprLiteral<T, V, Tag>ExprBool<T>éExprLiteral<T, boolean, "bool">ExprInt<T>éExprLiteral<T, number, "int">
- Valores de tipo são divididos em
TypeBooleTypeInt, e valores singleton também são fornecidos com os mesmos nomes- Como o TypeScript apaga completamente os tipos, nomes ligados a tipos e nomes ligados a valores existem em namespaces separados
- Essa característica permite definir tipo e valor com o mesmo nome
Visitor, transform, tipo de erro e desugaring
- Como o
switchdo TypeScript é uma instrução e não uma expressão, define-se um visitor para tratar de forma confortável os kinds de expressão- Métodos
bool,int,binaryeiftratam cada kind correspondente - O autocompletar do editor ajuda tanto nos casos de
switchquanto na implementação do visitor
- Métodos
transform<U, V>é uma função de travessia generalizada que converteExpr<U>emExpr<V>- A transformação é executada de baixo para cima, em estilo bottom-up
- Ao visitar um nó interno, as subexpressões já foram transformadas, então o tipo do visitor passa a ser
Visitor<U, V>e nãoVisitor<V, V>
- Aproveitando o fato de o TypeScript ser uma linguagem de tipagem dinâmica em runtime, também seria possível construir uma travessia mais genérica com
Object.keys- Mesmo assim, a assinatura estática da função pode ser preservada
- No exemplo isso não é estritamente necessário, mas existe a possibilidade de recorrer a essa abordagem dinâmica quando for útil
- Erros de tipo não são acumulados em um array como efeito colateral; eles são representados pelo tipo
TypeErrorTypepassa a serTypeBool | TypeInt | TypeErrorTypeErrorcontémtag: "Error",locationemessagetype_equalretorna true se qualquer um dos lados forError, evitando falhas em cascata
- O typechecker final verifica expressões binary e if
- Se os tipos dos operandos esquerdo e direito de uma expressão binary forem diferentes, ele retorna o erro
"binary expression operands have different types" - Se a condição da expressão if não for booleana, ele retorna o erro
"if condition is not a boolean" - Se os tipos dos branches then e else forem diferentes, ele retorna o erro
"if branches have different types"
- Se os tipos dos operandos esquerdo e direito de uma expressão binary forem diferentes, ele retorna o erro
- O resultado exige alguma quantidade de tipagem, mas o autocompletar compensa bastante, a sensação de lutar contra a linguagem é menor e o encaixe com o problema é natural
- As razões pelas quais o TypeScript pode ser produtivo como ferramenta para pequenos hacks de linguagem são resumidas em três pontos
- Deno é um runtime de scripting pequeno, coeso, poderoso e otimizado para um fluxo de desenvolvimento eficaz
- As ferramentas de TypeScript são úteis e produtivas no IDE, e com Deno não exigem configuração
- A linguagem é poderosa tanto em runtime quanto em compile time, permite expressões bem refinadas por meio de tipos e, quando necessário, deixa espaço para recorrer a abordagens dinâmicas
- Como ideia adicional, também é possível fazer desugaring type-safe de grande quantidade de açúcar sintático
- Em vez de parametrizar
ExpreExprKindpelos dados associados, eles são parametrizados recursivamente peloExprKindinteiro ExprKindCorerepresenta o conjunto básico de expressõesExprKindSugarinclui expressões básicas ou expressões que podem ser convertidas por desugaring para expressões básicasdesugar(expr: ExprSugar): ExprCorereduz uma expressão com açúcar sintático a uma expressão centraldesugar_one(expr: ExprKindSugar<ExprCore>): ExprKindCore<ExprCore>executa uma etapa de transformação quando as subexpressões já foram convertidas por desugaring
- Em vez de parametrizar
1 comentários
Opiniões no Hacker News
TypeScript é uma ótima linguagem de modo geral, e o fato de funções serem objetos que podem ter propriedades/métodos é subestimado
Dá para executar um array de funções como se fossem comandos e depois anexar descrições como
help, ou adicionar estado com closures/aplicação parcial, sem precisar definir cedo demais classes comoCommandEm orientação a objetos, dar nomes cedo demais cria muitos atritos, e a ideia é que passar os valores necessários para uma função é mais natural do que estruturas como
VideoCompressor#compress()Outras linguagens que dão suporte a objetos que se comportam como funções estão listadas em https://en.wikipedia.org/wiki/Function_object
Como nos handlers de
net/http, uma struct pode implementar o métodoserve, ou uma função handler pode chamar a si mesma para satisfazer a interfaceEm Clojure, dá para tratar isso de forma parecida anexando metadados ao
varde uma função; e, por ser Lisp, quase tudo é possível com macrosAlém disso, os canais CSP de
core/asyncseparam execução e comunicação, ajudando a evitar o problema de “cor” de funções de callbacks/promises/async/await, e comandos podem se comportar como produtores que enviam resultados para canaisSeja qual for o nome do método, ele pode ser usado em um contexto de função, sem precisar referenciar o nome concreto do método
Porém, não gosto da ideia de uma função ter propriedades para carregar estado e poder mudar de comportamento mesmo quando chamada com os mesmos argumentos. Vejo uma das grandes vantagens da programação funcional justamente em escapar do estado ao estilo da orientação a objetos
Funcpara funções com valor de retorno eActionpara funções sem valor de retornoAs expressões lambda de JavaScript se parecem bastante com as de C#, e as assinaturas de função de TypeScript e C# também são bem parecidas
Há também um pequeno repositório que mostra as semelhanças entre JavaScript, TypeScript e C#: https://github.com/CharlieDigital/js-ts-csharp
Captura de tela mostrando a mesma lógica lado a lado em JS/TS/C#: https://github.com/CharlieDigital/js-ts-csharp/blob/main/js-...
Não é tão surpreendente. No fim, vejo TypeScript como mais uma linguagem que incorporou, com dificuldade, uma boa parte dos recursos da família ML
A ausência de pattern matching de verdade a torna mais incômoda que OCaml, mas, em comparação com linguagens como C#, Swift, Dart e Kotlin, ela fica em um nível razoável
TypeScript tem um sistema de tipos poderoso, mas a biblioteca-padrão de base e a própria linguagem deixam a desejar, e não há pattern matching/expressões
switchDart tem um modelo de objetos fechado, com menos liberdade dinâmica, um sistema de tipos e expressões mais fracos, e quase não tem recursos de metaprogramação, o que leva a depender de boilerplate ao estilo Java e de geradores de código
C# é, entre as linguagens mencionadas, a que mais se aproxima dos recursos de ML, mas, ao contrário de TypeScript, não tem tipos soma, o que torna muita coisa mais trabalhosa
Por exemplo, dava para compor um parser que reconhece
{ ... }com outros parsers, e definir uma instrução como uma entre fluxo de controle/declaração/atribuiçãoO processamento de listas ao estilo ML e pattern matching eram muito expressivos ao lidar com representações intermediárias
No próprio texto, como
switchnão é uma expressão, foi preciso contornar isso com o padrão visitor, e o suporte a iteradores em JavaScript também é estranhamente limitadoExiste
.map(), mas ele só funciona em arrays e não pode ser aplicado diretamente a iteradores genéricosHá muitas diferenças, mas a transição não é difícil; pessoalmente, mesmo preferindo Kotlin, consigo usar ambas
Fico pensando como seria se TypeScript se afastasse da compatibilidade com JavaScript e compilasse para WASM. Kotlin está adicionando um compilador para WASM e já tem um transpilador para JS; o mesmo código carrega menor e mais rápido em WASM
JavaScript no navegador não é um bom alvo de compilação e, à medida que mais projetos novos já começam em TypeScript desde o início, o argumento de que é preciso facilitar a migração a partir de JavaScript existente vai ficando cada vez mais fraco
Ao alternar entre Rust e TypeScript, fica muito claro o quanto fazem falta recursos como enums tagged
A proposta de enums ADT parece ter parado; fico curioso se há outros esforços: https://github.com/Jack-Works/proposal-enum/discussions/19
O sistema de tipos do TypeScript é interessante, mas fico me perguntando quanto mais rápido seria se o compilador fosse escrito em uma linguagem compilada
Claro que isso parte da grande premissa de uma “boa implementação”
swceesbuildnão são bons pontos de comparação. Uma parte considerável do ganho de velocidade vem do fato de removerem a sintaxe específica de TypeScript para gerar JavaScriptO
tsctende a ser lento só na primeira execução e, usando a flagincrementalou o modo de observação--transpile-only, o tempo de compilação geralmente cai para menos de 100 ms, tornando a diferença percebida em relação ao SWC ou ao ESBuild quase inexistenteA ideia é que, se a verificação de tipos de um programa de porte médio leva 20 segundos, muitas vezes não é por ser JS, mas porque os tipos causam uma explosão combinatória
Outros runtimes podem trazer ganhos em paralelismo ou tempo de inicialização, mas ele diz não ter visto benchmarks centrados em CPU que sustentem uma melhoria geral de 20 vezes
Aí, depois de passar o dia inteiro depurando 5 linhas de lógica de tipos, você começa a se perguntar como foi parar ali
É um projeto escrito em Rust por um dos principais desenvolvedores do SWC; o SWC compila TS para JS, e o STC verifica tipos de TS
Dizem que o futuro isolated declaration mode pode reduzir o tempo de compilação em até 75%: https://github.com/microsoft/TypeScript/pull/53463#issuecomm...
Para quem está começando a aprender compiladores, recomendo este livro: https://keleshev.com/compiling-to-assembly-from-scratch/
O autor constrói um compilador para assembly ARM de 32 bits usando um subconjunto de TypeScript, e explica que ele parece quase pseudocódigo, o que o torna bem acessível
O livro é dividido em duas partes: na primeira, ele constrói um interpretador de linguagem em Java; na segunda, compila a mesma linguagem para bytecode e depois implementa em C uma máquina virtual para esse bytecode
Cada linha de código da implementação é referenciada no livro
Para evitar o padrão visitor, dá para usar um switch em estilo IIFE com uma função utilitária
runBasta usar um
switchdentro de uma função imediatamente executada e deixar o tipo de retorno ser inferidoSe quiser evitar IIFE porque não gosta do
()no fim, basta definir a função separadamente e depois chamá-laEstou escrevendo um compilador em TypeScript e concordo que é melhor do que eu esperava
No começo, assim como o autor, usei Deno, mas acabei migrando para Bun; apesar das arestas, estou mais satisfeito do que com Deno, e ele é muito rápido
Como front-end padrão para geração de parsers, Ohm-js é bastante agradável: https://ohmjs.org/
O compilador oficial
tscé grande demais, então não recomendo lê-lo; para ver como otscfunciona, mini-typescript é melhor: https://github.com/sandersn/mini-typescript/Em especial, a branch
centi-typescriptajuda bastante: https://github.com/sandersn/mini-typescript/tree/centi-types...Espero que GC e acesso ao DOM fiquem disponíveis em WASM
Achei que TypeScript teria uma sobrecarga adicional por causa das interfaces, então isso me surpreende
Fico curioso se isso poderia ser aplicado a outras áreas, por exemplo, se também seria bom para parsing de linguagens
Elas desaparecem completamente durante a compilação
O resultado não é tão surpreendente. Afinal, o próprio compilador TypeScript é escrito em TypeScript
TypeScript como ML já é validado todos os dias em ambientes de produção pesados
Já escrevi um compilador em C#, e o que parece especial aqui é basicamente o tipo union
Pessoalmente, optei por evitar a verbosidade do padrão visitor e estou aguardando um recurso de enums fechados para verificações de exaustividade em tempo de compilação
As alternativas geralmente são estranhas: ter N propriedades nullable em uma classe que representa um tipo soma, confiando numa condição documentada de que “sempre exatamente uma será non-null”, ou fazer várias classes herdarem de uma classe comum; ambas parecem pesadas
Para criar vários tipos union sobrepostos, os dois métodos acabam exigindo duplicação ou combinações engenhosas