Пишем кастомный трансформер AST на TypeScript
- пятница, 28 июня 2019 г. в 00:15:49
Команда TestMace снова с вами. На этот раз мы публикуем перевод статьи о преобразовании кода TypeScript, используя возможности компилятора. Приятного чтения!
Это мой первый пост, и в нём мне бы хотелось показать решение одной задачи с помощью API компилятора TypeScript. Чтобы найти это самое решение, я долгое время копался в многочисленных блогах и переваривал ответы на StackOverflow, поэтому, чтобы уберечь вас от такой же участи, я поделюсь всем тем, что я узнал о таком мощном, но слабо документированном наборе инструментов.
Основы API компилятора TypeScript (терминология парсера, API трансформации, многоуровневая архитектура), абстрактное синтаксическое дерево (AST), шаблон проектирования Visitor, генерация кода.
Если вы впервые слышите о концепции AST, я бы очень советовал прочесть эту статью от @Vaidehi Joshi. Вся её серия статей basecs вышла замечательной, вам точно понравится.
В Avero мы используем GraphQL и хотели бы добавить типобезопасность в резолверах. Однажды я наткнулся на graphqlgen, и с его помощью мне удалось решить многие проблемы касательно концепции моделей в GraphQL. Я не буду здесь углубляться в данный вопрос — для этого планирую написать отдельную статью. Если кратко, то модели описывают возвращаемые значения резолверов, и в graphqlgen эти модели связываются с интерфейсами посредством своего рода конфигурации (файл YAML или TypeScript с объявлением типов).
Во время работы мы запускаем микросервисы gRPC, и GQL по большей части служит фасадом. Мы уже опубликовали интерфейсы TypeScript, находящиеся в соответствии с proto контрактами, и я хотел использовать эти типы в качестве моделей, но столкнулся с некоторыми проблемами, вызванными поддержкой экспорта типов и тем, в каком виде реализовано описание наших интерфейсов (нагромождение пространств имён, большое количество ссылок).
По правилам хорошего тона работы с открытым кодом, моим первым действием было доработать то, что уже было сделано в репозитории graphqlgen и тем самым внести свой осмысленный вклад. Для реализации механизма интроспекции graphqlgen использует парсер @babel/parser, чтобы выполнить чтение файла и сбор информации об именах интерфейсов и объявлениях (полях интерфейсов).
Каждый раз, когда мне необходимо проделать что-либо с AST, я первым делом открываю astexplorer.net, а затем начинаю действовать. Этот инструмент позволяет проанализировать AST, созданное различными парсерами, в том числе babel/parser и парсером компилятора TypeScript. С помощью astexplorer.net можно визуализировать структуры данных, с которыми вам предстоит работать, и ознакомиться с типами узлов AST каждого парсера.
Взгляните на пример файла исходных данных и AST, созданное на его основе с помощью babel-parser:
import { protos } from 'my_company_protos'
export type User = protos.user.User;
{
"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"
}
]
}
Корень дерева (узел типа Program) содержит в своём теле два оператора — ImportDeclaration и ExportNamedDeclaration.
В ImportDeclaration нам особо интересны два свойства — source и specifiers, которые содержат информацию об исходном тексте. Например, в нашем случае значение source равно my_company_protos. По данному значению невозможно понять, относительный это путь к файлу или ссылка на внешний модуль. Именно это входит в задачи парсера.
Точно так же информация об исходном тексте содержится и в ExportNamedDeclaration. Пространства имён только усложняют данную структуру, добавляя в неё произвольную вложенность, в результате чего появляется всё больше и больше QualifiedTypeIdentifiers. Это ещё одна задача, которую нам предстоит решить в рамках выбранного подхода с парсером.
А я ведь даже ещё не дошёл до разрешения типов из импортов! Учитывая, что парсер и AST по умолчанию дают ограниченное количество информации об исходном тексте, то, чтобы добавить эту информацию в конечное дерево, необходимо производить парсинг всех импортируемых файлов. Но ведь каждый такой файл может иметь свои импорты!
Похоже, решая поставленные задачи с помощью парсера, мы получим слишком много кода… Сделаем шаг назад и подумаем ещё раз.
Нам не важны импорты, как не важна и структура файлов. Мы хотим, чтобы была возможность разрешить все свойства типа protos.user.User
и встроить их вместо использования ссылок на импорты. И где же взять нужную информацию о типах, чтобы слепить новый файл?
Поскольку мы выяснили, что решение с парсером не подходит для получения информации о типах импортируемых интерфейсов, давайте рассмотрим процесс компиляции TypeScript и попробуем найти другой выход.
Вот что сразу приходит на ум:
TypeChecker — основа системы типов TypeScript, и он может быть создан из экземпляра Program. Он отвечает за взаимодействия символов из различных файлов между собой, задавая символам типы и проводя семантическую проверку (например, обнаружение ошибок).
Первое, что делает TypeChecker, — это собирает все символы из различных исходных файлов в одно представление, после чего создаёт единую таблицу символов, производя "слияние" одних и тех же символов (например, пространств имён, встречающихся в нескольких разных файлах).
После инициализации исходного состояния TypeChecker готов предоставить ответы на любые вопросы о программе. Этими вопросами могут быть:
Какой символ соответствует данному узлу?
Какой у этого символа тип?
Какие символы являются видимыми в этой части AST?
Какие доступны сигнатуры для объявления функции?
Какие ошибки следует вывести для этого файла?
TypeChecker — это ровно то, что нам было нужно! Получив доступ к таблице символов и API, мы сможем ответить на первые два вопроса: Какой символ соответствует данному узлу? Какой у этого символа тип? Благодаря слиянию всех общих символов, TypeChecker даже сможет решить проблему с нагромождением пространств имён, о которой говорилось ранее!
Так как же добраться до этого API?
Вот один из примеров, которые я смог найти в сети. Из него видно, что доступ к TypeChecker можно получить через метод экземпляра Program. В нем есть два интересных метода — checker.getSymbolAtLocation
и checker.getTypeOfSymbolAtLocation
, которые выглядят очень похожими на то, что мы ищем.
Начнём работать над кодом.
import { protos } from './my_company_protos'
export type User = protos.user.User;
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;
}
}
}
import ts from "typescript";
// hardcode our input file
const filePath = "./src/models.ts";
// create a program instance, which is a collection of source files
// in this case we only have one source file
const program = ts.createProgram([filePath], {});
// pull off the typechecker instance from our program
const checker = program.getTypeChecker();
// get our models.ts source file AST
const source = program.getSourceFile(filePath);
// create TS printer instance which gives us utilities to pretty print our final AST
const printer = ts.createPrinter();
// helper to give us Node string type given kind
const syntaxToKind = (kind: ts.Node["kind"]) => {
return ts.SyntaxKind[kind];
};
// visit each node in the root AST and log its kind
ts.forEachChild(source, node => {
console.log(syntaxToKind(node.kind));
});
$ ts-node ./src/ts-alias.ts
prints
ImportDeclaration
TypeAliasDeclaration
EndOfFileToken
Нас интересует только объявление псевдонима типа, поэтому немного перепишем код:
ts.forEachChild(source, node => {
if (ts.isTypeAliasDeclaration(node)) {
console.log(node.kind);
}
})
// prints TypeAliasDeclaration
TypeScript предоставляет защиту для каждого типа узлов, с помощью которой можно узнать точный тип узла:
Теперь вернёмся к тем двум вопросам, которые были поставлены ранее: Какой символ соответствует данному узлу? Какой у этого символа тип?
Итак, мы получили имена, вводимые объявлениями интерфейсов псевдонимов типов, при помощи взаимодействия с таблицей символов TypeChecker. Пока мы всё ещё в самом начале пути, но это неплохая стартовая позиция с точки зрения интроспекции.
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);
// prints username, info
});
}
});
А теперь поразмыслим над генерацией кода.
Как было обозначено ранее, наша цель — парсинг и интроспекция исходного файла TypeScript и создание нового файла. Преобразование AST -> AST настолько часто используется, что команда TypeScript даже подумала над API для создания кастомных трансформеров!
Прежде чем перейти к основной задаче, опробуем создать простенький трансформер. Особая благодарность Джеймсу Гэрбатту (James Garbutt) за исходный шаблон для него.
Сделаем так, чтобы трансформер менял числовые литералы на строковые.
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);
/*
var two = "2";
var four = "4";
Самая важная его часть — это интерфейсы Visitor
и VisitorResult
:
type Visitor = (node: Node) => VisitResult<Node>;
type VisitResult<T extends Node> = T | T[] | undefined;
Главная цель при создании трансформера — написать Visitor. По логике вещей, необходимо реализовать рекурсивное прохождение каждого узла AST и возвращение результата VisitResult (один, несколько или ноль узлов AST). Можно настроить преобразователь таким образом, чтобы изменению поддавались только выбранные узлы.
// input
export namespace protos { // ModuleDeclaration
export namespace user { // ModuleDeclaration
// Module Block
export interface User { // InterfaceDeclaration
username: string; // username: string is PropertySignature
info: protos.Info.User; // TypeReference
}
}
export namespace Info {
export interface User {
name: protos.Info.Name; // TypeReference
}
export interface Name {
firstName: string;
lastName: string;
}
}
}
// this line is a TypeAliasDeclaration
export type User = protos.user.User; // protos.user.User is a TypeReference
// output
export interface User {
username: string;
info: { // info: { .. } is a TypeLiteral
name: { // name: { .. } is a TypeLiteral
firstName: string;
lastName: string;
}
}
}
Здесь можно посмотреть, с какими именно узлами мы будем работать.
Visitor должен выполнять два основных действия:
Вот так выглядит код Visitor-а:
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);
/*
Convert type references to type literals
interface IUser {
username: string
}
type User = IUser <--- IUser is a type reference
interface Context {
user: User <--- User is a type reference
}
In both cases we want to convert the type reference to
it's primitive literals. We want:
interface IUser {
username: string
}
type User = {
username: string
}
interface Context {
user: {
username: string
}
}
*/
if (ts.isTypeReferenceNode(node)) {
const symbol = checker.getSymbolAtLocation(node.typeName);
const type = checker.getDeclaredTypeOfSymbol(symbol);
const declarations = _.flatMap(checker.getPropertiesOfType(type), property => {
/*
Type references declarations may themselves have type references, so we need
to resolve those literals as well
*/
return _.map(property.declarations, visit);
});
return ts.createTypeLiteralNode(declarations.filter(ts.isTypeElement));
}
/*
Convert type alias to interface declaration
interface IUser {
username: string
}
type User = IUser
We want to remove all type aliases
interface IUser {
username: string
}
interface User {
username: string <-- Also need to resolve IUser
}
*/
if (ts.isTypeAliasDeclaration(node)) {
const symbol = checker.getSymbolAtLocation(node.name);
const type = checker.getDeclaredTypeOfSymbol(symbol);
const declarations = _.flatMap(checker.getPropertiesOfType(type), property => {
// Resolve type alias to it's literals
return _.map(property.declarations, visit);
});
// Create interface with fully resolved types
return ts.createInterfaceDeclaration(
[],
[ts.createToken(ts.SyntaxKind.ExportKeyword)],
node.name.getText(),
[],
[],
declarations.filter(ts.isTypeElement)
);
}
// Remove all export declarations
if (ts.isImportDeclaration(node)) {
return null;
}
return node;
};
return node => ts.visitNode(node, visit);
};
// Run source file through our transformer
const result = ts.transform(source, [typeAliasToInterfaceTransformer]);
// Create our output folder
const outputDir = path.resolve(__dirname, '../generated');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir);
}
// Write pretty printed transformed typescript to output directory
fs.writeFileSync(
path.resolve(__dirname, '../generated/models.ts'),
printer.printFile(_.first(result.transformed))
);
Мне нравится, как выглядит моё решение. Оно олицетворяет всю мощь хороших абстракций, интеллектуального компилятора, полезных инструментов для разработки (автодополнение VSCode, AST explorer и т.д.) и крупиц опыта других умелых разработчиков. Его полный исходный код с обновлениями можно найти здесь. Не уверен, насколько полезным оно окажется для более общих случев, отличных от моего частного. Я лишь хотел показать возможности набора инструментальных средств компилятора TypeScript, а также переложить на бумагу свои мысли по решению нестандартной задачи, которая долго меня беспокоила.
Надеюсь, что своим примером помогу кому-то упростить себе жизнь. Если тема AST, компиляторов и преобразований вам не до конца понятна, то перейдите по приведённым мной ссылкам на сторонние ресурсы и шаблоны, они должны вам помочь. Мне пришлось провести большое количество времени, изучая данную информацию, чтобы наконец найти решение. Мои первые попытки в частных репозиториях Github, включающие 45 // @ts-ignores
и assert-ы, заставляют меня краснеть от стыда.
Creating a TypeScript Transformer
TypeScript compiler APIs revisited
Наша команда создает крутой инструмент TestMace — мощная IDE для работы с API. Создавайте сценарии, тестируйте эндпоинты и пользуйтесь всей мощью продвинутого автодополнения и подсветки синтаксиса. Пишите нам! Мы тут: Telegram, Slack, Facebook, Vk