El equipo de TestMace está de vuelta contigo. Esta vez estamos publicando una traducción de un artículo sobre conversión de código TypeScript usando el compilador. Que tengas una buena lectura!
Introduccion
Esta es mi primera publicación, y en ella me gustaría mostrar una solución a un problema usando la API del compilador TypeScript. Para encontrar esta solución, profundicé en numerosos blogs durante mucho tiempo y digerí las respuestas en StackOverflow, por lo que para protegerte del mismo destino, compartiré todo lo que aprendí sobre una caja de herramientas tan poderosa pero mal documentada.
Conceptos clave
Los conceptos básicos de las API del compilador TypeScript (terminología del analizador, API de transformación, arquitectura en capas), árbol de sintaxis abstracta (AST), patrón de diseño de visitante, generación de código.
Pequeña recomendación
Si es la primera vez que escuchas sobre el concepto AST, te recomiendo leer este artículo de @Vaidehi Joshi . Su serie completa de artículos de basecs salió genial, te encantará.
Descripción de la tarea
En Avero, usamos GraphQL y nos gustaría agregar seguridad de tipos en los resolvers. Una vez me encontré con graphqlgen , y con él pude resolver muchos problemas relacionados con el concepto de modelos en GraphQL. No profundizaré en este tema aquí; para esto planeo escribir un artículo separado. Brevemente, los modelos describen los valores de retorno de los resolvers, y en graphqlgen estos modelos se comunican con las interfaces a través de un tipo de configuración (YAML o TypeScript con declaración de tipo).
Durante la operación, ejecutamos microservicios gRPC , y GQL en su mayor parte sirve como fachada. Ya hemos publicado interfaces TypeScript que están de acuerdo con los contratos proto , y quería usar estos tipos como modelos, pero encontré algunos problemas causados por el soporte para exportar tipos y en la forma en que se implementó la descripción de nuestras interfaces (acumulando espacios de nombres, una gran cantidad de enlaces).
De acuerdo con las reglas de buen gusto para trabajar con código fuente abierto, mi primer paso fue refinar lo que ya se ha hecho en el repositorio de graphqlgen y, por lo tanto, hacer mi contribución significativa. Para implementar el mecanismo de introspección, graphqlgen utiliza el analizador @ babel / parser para leer un archivo y recopilar información sobre nombres y declaraciones de interfaz (campos de interfaz).
Cada vez que necesito hacer algo con AST, primero abro astexplorer.net y luego empiezo a actuar. Esta herramienta le permite analizar el AST creado por varios analizadores, incluidos babel / parser y el compilador TypeScript. Con astexplorer.net, puede visualizar las estructuras de datos con las que tiene que trabajar y familiarizarse con los tipos de nodos AST de cada analizador.
Eche un vistazo al ejemplo del archivo de datos de origen y el AST creado sobre esta base utilizando babel-parser:
ejemplo.tsimport { protos } from 'my_company_protos' export type User = protos.user.User;
ast.json { "type": "Program", "start": 0, "end": 80, "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 3, "column": 36 } }, "comments": [], "range": [ 0, 80 ], "sourceType": "module", "body": [ { "type": "ImportDeclaration", "start": 0, "end": 42, "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 42 } }, "specifiers": [ { "type": "ImportSpecifier", "start": 9, "end": 15, "loc": { "start": { "line": 1, "column": 9 }, "end": { "line": 1, "column": 15 } }, "imported": { "type": "Identifier", "start": 9, "end": 15, "loc": { "start": { "line": 1, "column": 9 }, "end": { "line": 1, "column": 15 }, "identifierName": "protos" }, "name": "protos", "range": [ 9, 15 ], "_babelType": "Identifier" }, "importKind": null, "local": { "type": "Identifier", "start": 9, "end": 15, "loc": { "start": { "line": 1, "column": 9 }, "end": { "line": 1, "column": 15 }, "identifierName": "protos" }, "name": "protos", "range": [ 9, 15 ], "_babelType": "Identifier" }, "range": [ 9, 15 ], "_babelType": "ImportSpecifier" } ], "importKind": "value", "source": { "type": "Literal", "start": 23, "end": 42, "loc": { "start": { "line": 1, "column": 23 }, "end": { "line": 1, "column": 42 } }, "extra": { "rawValue": "my_company_protos", "raw": "'my_company_protos'" }, "value": "my_company_protos", "range": [ 23, 42 ], "_babelType": "StringLiteral", "raw": "'my_company_protos'" }, "range": [ 0, 42 ], "_babelType": "ImportDeclaration" }, { "type": "ExportNamedDeclaration", "start": 44, "end": 80, "loc": { "start": { "line": 3, "column": 0 }, "end": { "line": 3, "column": 36 } }, "specifiers": [], "source": null, "exportKind": "type", "declaration": { "type": "TypeAlias", "start": 51, "end": 80, "loc": { "start": { "line": 3, "column": 7 }, "end": { "line": 3, "column": 36 } }, "id": { "type": "Identifier", "start": 56, "end": 60, "loc": { "start": { "line": 3, "column": 12 }, "end": { "line": 3, "column": 16 }, "identifierName": "User" }, "name": "User", "range": [ 56, 60 ], "_babelType": "Identifier" }, "typeParameters": null, "right": { "type": "GenericTypeAnnotation", "start": 63, "end": 79, "loc": { "start": { "line": 3, "column": 19 }, "end": { "line": 3, "column": 35 } }, "typeParameters": null, "id": { "type": "QualifiedTypeIdentifier", "start": 63, "end": 79, "loc": { "start": { "line": 3, "column": 19 }, "end": { "line": 3, "column": 35 } }, "qualification": { "type": "QualifiedTypeIdentifier", "start": 63, "end": 74, "loc": { "start": { "line": 3, "column": 19 }, "end": { "line": 3, "column": 30 } }, "qualification": { "type": "Identifier", "start": 63, "end": 69, "loc": { "start": { "line": 3, "column": 19 }, "end": { "line": 3, "column": 25 }, "identifierName": "protos" }, "name": "protos", "range": [ 63, 69 ], "_babelType": "Identifier" }, "range": [ 63, 74 ], "_babelType": "QualifiedTypeIdentifier" }, "range": [ 63, 79 ], "_babelType": "QualifiedTypeIdentifier" }, "range": [ 63, 79 ], "_babelType": "GenericTypeAnnotation" }, "range": [ 51, 80 ], "_babelType": "TypeAlias" }, "range": [ 44, 80 ], "_babelType": "ExportNamedDeclaration" } ] }
La raíz del árbol (un nodo del tipo Programa ) contiene dos operadores en su cuerpo: ImportDeclaration y ExportNamedDeclaration .
En ImportDeclaration, estamos particularmente interesados en dos propiedades: fuente y especificadores , que contienen información sobre el texto fuente. Por ejemplo, en nuestro caso, el valor de la fuente es igual a my_company_protos . Es imposible entender por este valor si se trata de una ruta relativa a un archivo o un enlace a un módulo externo. Esto es exactamente lo que hace el analizador.
Del mismo modo, la información de origen está contenida en ExportNamedDeclaration . Los espacios de nombres solo complican esta estructura al agregarle anidamiento arbitrario, lo que resulta en más y más QualifiedTypeIdentifiers . Esta es otra tarea que tenemos que resolver en el marco del enfoque elegido con el analizador.
¡Pero aún no he llegado a la resolución de tipos de importaciones! Dado que el analizador y el AST proporcionan de forma predeterminada una cantidad limitada de información sobre el texto fuente, para agregar esta información al árbol final, es necesario analizar todos los archivos importados. ¡Pero cada archivo puede tener sus propias importaciones!
Parece que al resolver las tareas con la ayuda del analizador, obtenemos demasiado código ... Retrocedamos y pensemos nuevamente.
Las importaciones no son importantes para nosotros, así como la estructura del archivo no es importante. Queremos poder habilitar todas las propiedades del tipo protos.user.User
e incrustarlas en lugar de utilizar referencias de importación. ¿Y dónde obtener la información de tipo necesaria para crear un nuevo archivo?
Typechecker
Como descubrimos que la solución con el analizador no es adecuada para obtener información sobre los tipos de interfaces importadas, veamos el proceso de compilación de TypeScript e intentemos encontrar otra salida.
Esto es lo que viene a la mente de inmediato:
TypeChecker es la base del sistema de tipos TypeScript, y se puede crear desde una instancia del Programa. Es responsable de la interacción de los caracteres de varios archivos entre sí, configurando tipos de caracteres y realizando una verificación semántica (por ejemplo, detección de errores).
Lo primero que hace TypeChecker es recopilar todos los caracteres de diferentes archivos de origen en una vista y luego crear una tabla de caracteres única, fusionando los mismos caracteres (por ejemplo, espacios de nombres encontrados en varios archivos diferentes).
Después de inicializar el estado inicial, TypeChecker está listo para proporcionar respuestas a cualquier pregunta sobre el programa. Estas preguntas pueden incluir:
¿Qué símbolo corresponde a este nodo?
¿Qué tipo de símbolo es este?
¿Qué caracteres son visibles en esta parte del AST?
¿Qué firmas están disponibles para declarar una función?
¿Qué errores se deben generar para este archivo?
¡TypeChecker es exactamente lo que necesitábamos! Al tener acceso a la tabla de símbolos y a la API, podemos responder las dos primeras preguntas: ¿Qué símbolo corresponde a este nodo? ¿Qué tipo de símbolo es este? ¡Al fusionar todos los caracteres comunes, TypeChecker incluso podrá resolver el problema con la acumulación de espacios de nombres que se mencionó anteriormente!
Entonces, ¿cómo llegas a esta API?
Aquí hay un ejemplo que pude encontrar en la red. Muestra que se puede acceder a TypeChecker a través del método de instancia del Programa. Tiene dos métodos interesantes: checker.getSymbolAtLocation
y checker.getTypeOfSymbolAtLocation
, que se parecen mucho a lo que estamos buscando.
Comencemos a trabajar en el código.
modelos.ts import { protos } from './my_company_protos' export type User = protos.user.User;
my_company_protos.ts export namespace protos { export namespace user { export interface User { username: string; info: protos.Info.User; } } export namespace Info { export interface User { name: protos.Info.Name; } export interface Name { firstName: string; lastName: string; } } }
ts-alias.ts import ts from "typescript";
$ ts-node ./src/ts-alias.ts prints ImportDeclaration TypeAliasDeclaration EndOfFileToken
Solo estamos interesados en declarar un alias de tipo, por lo que reescribimos el código un poco:
kind-printer.ts ts.forEachChild(source, node => { if (ts.isTypeAliasDeclaration(node)) { console.log(node.kind); } })
TypeScript proporciona protección para cada tipo de nodo, con el que puede averiguar el tipo exacto de nodo:

Ahora volvamos a las dos preguntas que se plantearon anteriormente: ¿Qué símbolo corresponde a este nodo? ¿Qué tipo de símbolo es este?
Entonces, obtuvimos los nombres ingresados por las declaraciones de la interfaz de alias de tipo al interactuar con la tabla de caracteres TypeChecker . Aunque todavía estamos al comienzo del viaje, esta es una buena posición de partida desde el punto de vista de la introspección .
checker-example.ts ts.forEachChild(source, node => { if (ts.isTypeAliasDeclaration(node)) { const symbol = checker.getSymbolAtLocation(node.name); const type = checker.getDeclaredTypeOfSymbol(symbol); const properties = checker.getPropertiesOfType(type); properties.forEach(declaration => { console.log(declaration.name);
Ahora pensemos en la generación de código .
Como se indicó anteriormente, nuestro objetivo es analizar e introspectar el archivo fuente de TypeScript y crear un nuevo archivo. La conversión AST -> AST se usa con tanta frecuencia que el equipo de TypeScript incluso pensó en una API para crear transformadores personalizados .
Antes de pasar a la tarea principal, intentaremos crear un transformador simple. Un agradecimiento especial a James Garbutt por la plantilla original para él.
Hagamos que el transformador cambie los literales numéricos a los de cadena.
number-transformer.ts const source = ` const two = 2; const four = 4; `; function numberTransformer<T extends ts.Node>(): ts.TransformerFactory<T> { return context => { const visit: ts.Visitor = node => { if (ts.isNumericLiteral(node)) { return ts.createStringLiteral(node.text); } return ts.visitEachChild(node, child => visit(child), context); }; return node => ts.visitNode(node, visit); }; } let result = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS }, transformers: { before: [numberTransformer()] } }); console.log(result.outputText);
La parte más importante son las VisitorResult
Visitor
y VisitorResult
:
type Visitor = (node: Node) => VisitResult<Node>; type VisitResult<T extends Node> = T | T[] | undefined;
El objetivo principal al crear un transformador es escribir Visitor . Lógicamente, es necesario implementar un recorrido recursivo de cada nodo AST y devolver un resultado VisitResult (uno, varios o cero nodos AST). Puede configurar el inversor para que solo los nodos seleccionados respondan al cambio.
Aquí puede ver con qué nodos trabajaremos.
El visitante debe realizar dos acciones principales:
- Reemplazo de declaraciones de tipo Alias con declaraciones de interfaz
- Conversión de TypeReferences a TypeLiterals
Solución
Así es como se ve el código de visitante:
aliasTransformer.ts import path from 'path'; import ts from 'typescript'; import _ from 'lodash'; import fs from 'fs'; const filePath = path.resolve(_.first(process.argv.slice(2))); const program = ts.createProgram([filePath], {}); const checker = program.getTypeChecker(); const source = program.getSourceFile(filePath); const printer = ts.createPrinter(); const typeAliasToInterfaceTransformer: ts.TransformerFactory<ts.SourceFile> = context => { const visit: ts.Visitor = node => { node = ts.visitEachChild(node, visit, context); if (ts.isTypeReferenceNode(node)) { const symbol = checker.getSymbolAtLocation(node.typeName); const type = checker.getDeclaredTypeOfSymbol(symbol); const declarations = _.flatMap(checker.getPropertiesOfType(type), property => { return _.map(property.declarations, visit); }); return ts.createTypeLiteralNode(declarations.filter(ts.isTypeElement)); } if (ts.isTypeAliasDeclaration(node)) { const symbol = checker.getSymbolAtLocation(node.name); const type = checker.getDeclaredTypeOfSymbol(symbol); const declarations = _.flatMap(checker.getPropertiesOfType(type), property => {
Me gusta cómo se ve mi solución. Incorpora el poder de buenas abstracciones, un compilador inteligente, herramientas de desarrollo útiles (VSCode de autocompletado, explorador AST, etc.) y un poco de experiencia de otros desarrolladores expertos. Su código fuente completo con actualizaciones se puede encontrar aquí . No estoy seguro de lo útil que será para casos más generales, aparte de mi privado. Solo quería mostrar las capacidades del kit de herramientas del compilador TypeScript, y también poner mis pensamientos en papel para resolver un problema no estándar que me molestó durante mucho tiempo.
Espero que mi ejemplo ayude a alguien a simplificar sus vidas. Si el tema de AST, compiladores y transformaciones no es completamente comprendido por usted, entonces siga los enlaces a recursos y plantillas de terceros que le proporcioné, deberían ayudarlo. Tuve que pasar mucho tiempo estudiando esta información para finalmente encontrar una solución. Mis primeros intentos de repositorios privados de Github, incluyendo 45 // @ts-ignores
y afirmaciones, me hicieron sonrojar de vergüenza.
Recursos que me ayudaron:
Microsoft / TypeScript
Crear un transformador de TypeScript
API del compilador de TypeScript revisitada
Explorador AST