TypeScript es sorprendentemente bastante bueno incluso para implementar compiladores
(matklad.github.io)- Tradicionalmente, el lenguaje de implementación para herramientas tipo compilador se dividía entre OCaml y C++, pero en experimentos pequeños de lenguajes, TypeScript puede ser una opción ligera de usar, parecida a un lenguaje de la familia ML
- Rust combina las ventajas de ML y C++, y además ofrece multihilo seguro, pero como hay que modelar la disposición física de los datos, puede volverse una carga grande para prototipos pequeños
- Deno permite empezar rápido con experimentos de lenguaje en TypeScript gracias a su binario único, linting y formateo integrados, ausencia de etapa de compilación, task runner y modo watch
- El type checker de ejemplo combina AST genérico, tagged unions, visitor y transformaciones bottom-up para convertir
Expr<void>enExpr<Type>, e introduceTypeErrorcomo valor de tipo para reducir errores en cadena - TypeScript puede ser productivo como herramienta para hackear lenguajes pequeños gracias al autocompletado, su sistema de tipos flexible y las características de runtime que permiten salirse hacia un enfoque dinámico cuando hace falta
El panorama tradicional al elegir un lenguaje de implementación
- Al crear herramientas con forma de compilador, la elección del lenguaje de implementación suele dividirse en dos grandes corrientes
- Para trabajos centrados en el lenguaje, como especificaciones formales o lenguajes de juguete/hobby, OCaml encaja muy bien
- Ejemplos: plzoo y el WebAssembly reference interpreter
- Para trabajos enfocados en la implementación y que necesitan estar listos para producción, C++ se elige con frecuencia
- LLVM, clang, v8 y HotSpot están basados en C++
- Rust recibió influencia directa de ML y C++, combina ventajas de ambos y además aporta fortalezas propias, como multihilo seguro
- Aun así, dentro de ese espectro está más inclinado hacia la preparación para producción
- Un sistema de build que “simplemente funciona” también ayuda al prototipado, pero viene acompañado de la complejidad adicional de tener que modelar la disposición física de los datos
Ventajas del enfoque con índices en Rust y la carga que supone para código pequeño
- Al hacer compiladores en Rust, un consejo común es evitar punteros y usar índices
- Los índices tienen varias ventajas en bases de código grandes
- Permiten ubicar side tables dentro de módulos relacionados, reduciendo el acoplamiento
- Los índices pueden ser
u32y favorecen una disposición tipo struct-of-arrays, lo que beneficia el rendimiento - Facilitan la serialización o la conexión con frameworks de compilación incremental, dando más flexibilidad a la estrategia de cómputo
- Pero en programación a pequeña escala, el enfoque con índices en sí se vuelve engorroso, y en experimentos de hobby esa carga puede ser fatal
- OCaml sigue dejando una sensación de antigüedad y, en este contexto, se explora si TypeScript puede servir como una alternativa equivalente a ML
Deno y TypeScript como entorno para experimentar rápido
- Se usa deno como entorno de arranque
- Ofrece una experiencia lista para usar con TypeScript directamente
- En OCaml este punto suele ser doloroso, y aunque Rust es mejor que OCaml o C++, Deno ofrece una experiencia todavía más simple que Rust
- La experiencia de desarrollo de Deno encaja bien con pequeños hacks de PLT
- Es un binario único
- Tiene linting y formateo integrados
- No requiere una etapa de compilación separada
- Incluye task runner y modo watch
- TypeScript en sí ofrece un sistema de tipos suficientemente flexible y con poca carga sintáctica
Patrones de TypeScript vistos a través de un type checker pequeño
- El AST empieza con expresiones que contienen información de ubicación en archivo
Locationtienefile,lineycolumn- En TypeScript, las cadenas son simplemente
stringy los números simplementenumber, así que no hace falta preocuparse por distinciones comousizeyu32
- Las expresiones separan ubicación y kind, y más adelante se generalizan como datos asociados con la forma
Expr<T>- Justo después del parsing, las expresiones tienen datos
void - Las expresiones procesadas por el type checker tienen datos
Type - La función de inferencia de tipos recibe
ast.Expr<void>y devuelveast.Expr<Type>
- Justo después del parsing, las expresiones tienen datos
- Como TypeScript no agrega comportamiento de runtime automáticamente, para hacer matching sobre union types hay que introducir información de tipo en runtime manualmente
- Campos como
tag: "binary"ytag: "if"cumplen ese papel tag: "binary"significa que, en runtime, ese valor solo puede ser la cadena"binary"
- Campos como
- Los literales booleanos e integer tienen formas casi idénticas, así que se abstraen como
ExprLiteral<T, V, Tag>ExprBool<T>esExprLiteral<T, boolean, "bool">ExprInt<T>esExprLiteral<T, number, "int">
- Los valores de tipo se dividen en
TypeBoolyTypeInt, y los valores singleton se ofrecen con los mismos nombres- TypeScript elimina completamente los tipos, así que los nombres relacionados con tipos y con valores existen en namespaces separados
- Esa característica permite definir tipos y valores con el mismo nombre
Visitor, transform, tipo de error y desugaring
- Como
switchen TypeScript es una sentencia y no una expresión, se define un visitor para manejar cómodamente los kind de expresiones- Los métodos
bool,int,binaryeifprocesan cada kind correspondiente - El autocompletado del editor ayuda tanto con los casos de
switchcomo con la implementación del visitor
- Los métodos
transform<U, V>es una función de recorrido generalizada que convierteExpr<U>enExpr<V>- La transformación se ejecuta bottom-up
- Al visitar nodos internos, las subexpresiones ya fueron transformadas, por lo que el tipo del visitor pasa a ser
Visitor<V, V>y noVisitor<U, V>
- Aprovechando que TypeScript es un lenguaje de tipos dinámicos en runtime, también se puede crear un recorrido más general con
Object.keys- Incluso en ese caso se puede mantener una firma estática de función
- En el ejemplo no es estrictamente necesario, pero queda abierta la posibilidad de recurrir a un enfoque dinámico cuando haga falta
- Los errores de tipo no se acumulan como efecto secundario en un arreglo, sino que se representan mediante el tipo
TypeErrorTypepasa a serTypeBool | TypeInt | TypeErrorTypeErrortienetag: "Error",locationymessagetype_equaldevuelve true si alguno de los lados esError, para evitar fallos en cascada
- El type checker final valida expresiones binary e if
- Si los tipos de los operandos izquierdo y derecho de una expresión binary no coinciden, devuelve el error
"binary expression operands have different types" - Si la condición de una expresión if no es booleana, devuelve
"if condition is not a boolean" - Si los tipos de las ramas then y else no coinciden, devuelve
"if branches have different types"
- Si los tipos de los operandos izquierdo y derecho de una expresión binary no coinciden, devuelve el error
- El resultado requiere algo de tipado, pero el autocompletado compensa bastante, hay menos sensación de pelearse con el lenguaje y se adapta de forma natural a la forma del problema
- Las razones por las que TypeScript resulta productivo como herramienta para hackear lenguajes pequeños se resumen en tres puntos
- Deno es un runtime de scripting pequeño, autocontenido, potente y optimizado para un flujo de desarrollo eficaz
- Las herramientas de TypeScript son útiles y productivas en el IDE, y gracias a Deno no requieren configuración
- El lenguaje es potente tanto en runtime como en compile time, permite expresiones bastante sofisticadas con tipos y, si hace falta, también permite caer en un enfoque dinámico
- Como idea adicional, también es posible desugar mucho azúcar sintáctico de forma type-safe
ExpryExprKindse parametrizan recursivamente no por datos asociados, sino sobre todo elExprKindExprKindCorerepresenta el conjunto de expresiones baseExprKindSugarincluye expresiones base o expresiones que pueden desugarse a expresiones basedesugar(expr: ExprSugar): ExprCorereduce una expresión con azúcar sintáctico a una expresión núcleodesugar_one(expr: ExprKindSugar<ExprCore>): ExprKindCore<ExprCore>realiza una transformación de un paso cuando las subexpresiones ya están desugarizadas
1 comentarios
Opiniones de Hacker News
TypeScript es, en general, un lenguaje excelente, y está subestimado el hecho de que las funciones sean objetos que pueden tener propiedades/métodos.
Puedes ejecutar un arreglo de funciones como si fueran comandos y luego agregarles descripciones como
help, o añadir estado mediante closures/aplicación parcial, así que no hace falta definir apresuradamente clases comoCommand.Creo que en la programación orientada a objetos poner nombres demasiado pronto genera muchos conflictos, y que es más natural pasarle a una función los valores necesarios que usar una estructura como
VideoCompressor#compress().Otros lenguajes que admiten objetos que se comportan como funciones están recopilados en https://en.wikipedia.org/wiki/Function_object.
Como con los handlers de
net/http, una struct puede implementar un métodoserve, o una función handler puede llamarse a sí misma para satisfacer una interfaz.En Clojure, se puede hacer algo parecido adjuntando metadatos al
varde una función y, al ser Lisp, con macros se puede hacer casi cualquier cosa.Además, los canales CSP de
core/asyncseparan ejecución y comunicación, lo que permite evitar el problema del color de las funciones de callbacks/promesas/async/await; los comandos pueden comportarse como productores que envían resultados a un canal.Se pueden usar en un contexto de función sin importar cuál sea el nombre del método, y no hace falta referirse al nombre concreto del método.
Sin embargo, no me gusta que una función tenga propiedades, contenga estado y pueda cambiar su comportamiento aunque se la llame con los mismos argumentos. Creo que una gran ventaja de la programación funcional está en alejarse del estado al estilo de la orientación a objetos.
Funcpara funciones con valor de retorno yActionpara funciones sin valor de retorno.Las expresiones lambda de JavaScript se parecen bastante a las de C#, y las firmas de función de TypeScript y C# también son bastante similares.
También hay un pequeño repositorio que muestra las similitudes entre JavaScript, TypeScript y C#: https://github.com/CharlieDigital/js-ts-csharp
Captura que muestra la misma lógica lado a lado en JS/TS/C#: https://github.com/CharlieDigital/js-ts-csharp/blob/main/js-...
No es tan sorprendente. Al final, veo a TypeScript como otro lenguaje que incorporó con dificultad una buena parte de las funciones de la familia ML.
Al no tener verdadero pattern matching, es más incómodo que OCaml, pero comparado con lenguajes como C#, Swift, Dart o Kotlin, está en un nivel aceptable.
TypeScript tiene un sistema de tipos potente, pero la biblioteca estándar base y el propio lenguaje dejan que desear, y no tiene pattern matching ni expresiones
switch.Dart tiene un modelo de objetos cerrado, con menos libertad dinámica; su sistema de tipos y sus expresiones también son más débiles, y casi no tiene mecanismos de metaprogramación, por lo que termina dependiendo de boilerplate al estilo Java y generadores de código.
C# es, entre los lenguajes mencionados, el más cercano a las funciones de ML, pero a diferencia de TypeScript no tiene tipos suma, lo que vuelve muchas cosas más engorrosas.
Por ejemplo, se podía combinar un parser que reconoce
{ ... }con otros parsers, y definir una sentencia como una de varias opciones: flujo de control, declaración o asignación.El procesamiento de listas y el pattern matching al estilo ML eran muy expresivos al manejar la representación intermedia.
Incluso en el texto principal, como
switchno es una expresión, hubo que esquivarlo con el patrón visitor, y el soporte de iteradores de JavaScript también es extrañamente limitado.Existe
.map(), pero solo funciona con arreglos y no se puede aplicar directamente a iteradores generales.Hay muchas diferencias, pero no es difícil pasar de uno a otro, y aunque personalmente prefiero Kotlin, puedo usar ambos.
Me pregunto qué pasaría si TypeScript se apartara de la compatibilidad con JavaScript y compilara a WASM. Kotlin está agregando un compilador a WASM y ya tiene un transpiler a JS; el mismo código carga de forma más pequeña y rápida en WASM.
JavaScript en el navegador no es un buen objetivo de compilación y, a medida que más proyectos nuevos empiezan desde cero con TypeScript, también se debilita cada vez más la razón de tener que facilitar la transición desde JavaScript existente.
Al alternar entre Rust y TypeScript, se nota con mucha claridad cuánto se extrañan funciones como los enums etiquetados.
La propuesta de enums ADT parece estar detenida; me pregunto si hay otros esfuerzos: https://github.com/Jack-Works/proposal-enum/discussions/19
El sistema de tipos de TypeScript es interesante, pero uno se pregunta qué tan rápido sería si el compilador estuviera escrito en un lenguaje compilado
Claro, hace falta la gran premisa de que sea una “buena implementación”
swcyesbuildno son buenos puntos de comparación. Buena parte de la mejora de velocidad se debe a que eliminan la sintaxis específica de TypeScript para generar JavaScripttsctiende a ser lento solo en la primera ejecución y, si se usa la banderaincrementalo el modo de vigilancia--transpile-only, normalmente el tiempo de compilación baja de 100 ms, así que casi no hay diferencia perceptible frente a SWC o ESBuildLa idea es que, si la verificación de tipos de un programa mediano tarda 20 segundos, por lo general no es por estar en JS, sino porque los tipos provocan una explosión combinatoria
Otros runtimes pueden aportar beneficios en paralelismo o tiempo de arranque, pero dice que no ha visto benchmarks centrados en CPU que respalden una mejora general de 20 veces en velocidad
Luego pasas todo el día depurando 5 líneas de lógica de tipos y terminas preguntándote cómo llegaste hasta ahí
Es un proyecto escrito en Rust por uno de los principales desarrolladores de SWC: SWC compila TS a JS y STC verifica los tipos de TS
Dicen que el futuro isolated declaration mode puede reducir los tiempos de compilación hasta en 75%: https://github.com/microsoft/TypeScript/pull/53463#issuecomm...
Para quienes apenas empiezan a aprender sobre compiladores, recomiendo este libro: https://keleshev.com/compiling-to-assembly-from-scratch/
El autor crea un compilador a ensamblador ARM de 32 bits usando un subconjunto de TypeScript, y explica que se ve casi como pseudocódigo, lo que lo hace accesible
El libro está dividido en dos partes: en la primera se construye un intérprete de lenguaje en Java; en la segunda se compila el mismo lenguaje a bytecode y luego se implementa una máquina virtual de bytecode en C
Todas las líneas de código de la implementación aparecen referenciadas en el libro
Para evitar el patrón visitor, se puede usar una función utilitaria
runcon un switch estilo IIFEEs decir, usar
switchdentro de una función ejecutada inmediatamente y dejar que se infiera el tipo de retornoSi quieres evitar una IIFE porque no te gusta el
()del final, puedes definir la función aparte y luego llamarlaEstoy escribiendo un compilador en TypeScript y coincido en que no es tan malo como pensaba
Al principio, como el autor, empecé con Deno, pero al final me pasé a Bun; aunque tiene partes ásperas, me resulta más satisfactorio que Deno y es muy rápido
Como frontend estándar de generador de parsers, Ohm-js es bastante cómodo: https://ohmjs.org/
El compilador oficial
tsces demasiado enorme, así que no recomiendo leerlo; para ver cómo funcionatsc, mini-typescript es mejor: https://github.com/sandersn/mini-typescript/En particular, la rama
centi-typescriptayuda mucho: https://github.com/sandersn/mini-typescript/tree/centi-types...Espero que en WASM se habiliten GC y acceso al DOM
Pensé que TypeScript tendría una sobrecarga adicional por las interfaces, así que me sorprende
Me pregunto si se podría aplicar a otros ámbitos; por ejemplo, si también sería bueno para parseo de lenguajes
Desaparecen por completo durante la compilación
El resultado no es tan sorprendente. Después de todo, el propio compilador de TypeScript está escrito en TypeScript
TypeScript como ML ya se valida todos los días en entornos de producción pesados
He escrito un compilador en C#, y lo que aquí parece especial son más que nada los tipos unión
Personalmente decidí evitar la verbosidad del patrón visitor, y estoy esperando una funcionalidad de enums cerrados para poder verificar exhaustividad en tiempo de compilación
Las alternativas suelen ser incómodas: tener N propiedades nullable en una clase que representa un tipo suma y depender de la condición documentada de que “siempre solo una sea non-null”, o hacer que varias clases hereden de una clase común; ambas se sienten pesadas
Si quieres crear varios tipos unión que se solapen, en ambos enfoques terminas necesitando duplicación o combinaciones ingeniosas