Pull to refresh

Пишем кастомный трансформер AST на TypeScript

Reading time11 min
Views7.5K
Original author: Kevin Saldaña

Команда 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:


example.ts
import { 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"
    }
  ]
}

Корень дерева (узел типа Program) содержит в своём теле два оператора — ImportDeclaration и ExportNamedDeclaration.


В ImportDeclaration нам особо интересны два свойства — source и specifiers, которые содержат информацию об исходном тексте. Например, в нашем случае значение source равно my_company_protos. По данному значению невозможно понять, относительный это путь к файлу или ссылка на внешний модуль. Именно это входит в задачи парсера.


Точно так же информация об исходном тексте содержится и в ExportNamedDeclaration. Пространства имён только усложняют данную структуру, добавляя в неё произвольную вложенность, в результате чего появляется всё больше и больше QualifiedTypeIdentifiers. Это ещё одна задача, которую нам предстоит решить в рамках выбранного подхода с парсером.


А я ведь даже ещё не дошёл до разрешения типов из импортов! Учитывая, что парсер и AST по умолчанию дают ограниченное количество информации об исходном тексте, то, чтобы добавить эту информацию в конечное дерево, необходимо производить парсинг всех импортируемых файлов. Но ведь каждый такой файл может иметь свои импорты!


Похоже, решая поставленные задачи с помощью парсера, мы получим слишком много кода… Сделаем шаг назад и подумаем ещё раз.


Нам не важны импорты, как не важна и структура файлов. Мы хотим, чтобы была возможность разрешить все свойства типа protos.user.User и встроить их вместо использования ссылок на импорты. И где же взять нужную информацию о типах, чтобы слепить новый файл?


TypeChecker


Поскольку мы выяснили, что решение с парсером не подходит для получения информации о типах импортируемых интерфейсов, давайте рассмотрим процесс компиляции TypeScript и попробуем найти другой выход.


Вот что сразу приходит на ум:


TypeChecker — основа системы типов TypeScript, и он может быть создан из экземпляра Program. Он отвечает за взаимодействия символов из различных файлов между собой, задавая символам типы и проводя семантическую проверку (например, обнаружение ошибок).
Первое, что делает TypeChecker, — это собирает все символы из различных исходных файлов в одно представление, после чего создаёт единую таблицу символов, производя "слияние" одних и тех же символов (например, пространств имён, встречающихся в нескольких разных файлах).
После инициализации исходного состояния TypeChecker готов предоставить ответы на любые вопросы о программе. Этими вопросами могут быть:
Какой символ соответствует данному узлу?
Какой у этого символа тип?
Какие символы являются видимыми в этой части AST?
Какие доступны сигнатуры для объявления функции?
Какие ошибки следует вывести для этого файла?

TypeChecker — это ровно то, что нам было нужно! Получив доступ к таблице символов и API, мы сможем ответить на первые два вопроса: Какой символ соответствует данному узлу? Какой у этого символа тип? Благодаря слиянию всех общих символов, TypeChecker даже сможет решить проблему с нагромождением пространств имён, о которой говорилось ранее!


Так как же добраться до этого API?


Вот один из примеров, которые я смог найти в сети. Из него видно, что доступ к TypeChecker можно получить через метод экземпляра Program. В нем есть два интересных метода — checker.getSymbolAtLocation и checker.getTypeOfSymbolAtLocation, которые выглядят очень похожими на то, что мы ищем.


Начнём работать над кодом.


models.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";

// 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

Нас интересует только объявление псевдонима типа, поэтому немного перепишем код:


kind-printer.ts
ts.forEachChild(source, node => {
  if (ts.isTypeAliasDeclaration(node)) {
    console.log(node.kind);
  }
})
// prints TypeAliasDeclaration

TypeScript предоставляет защиту для каждого типа узлов, с помощью которой можно узнать точный тип узла:



Теперь вернёмся к тем двум вопросам, которые были поставлены ранее: Какой символ соответствует данному узлу? Какой у этого символа тип?


Итак, мы получили имена, вводимые объявлениями интерфейсов псевдонимов типов, при помощи взаимодействия с таблицей символов TypeChecker. Пока мы всё ещё в самом начале пути, но это неплохая стартовая позиция с точки зрения интроспекции.


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);
      // prints username, info
    });
  }
});

А теперь поразмыслим над генерацией кода.


API трансформации


Как было обозначено ранее, наша цель — парсинг и интроспекция исходного файла TypeScript и создание нового файла. Преобразование AST -> AST настолько часто используется, что команда TypeScript даже подумала над API для создания кастомных трансформеров!


Прежде чем перейти к основной задаче, опробуем создать простенький трансформер. Особая благодарность Джеймсу Гэрбатту (James Garbutt) за исходный шаблон для него.


Сделаем так, чтобы трансформер менял числовые литералы на строковые.


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);

/*
  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-output.ts
// 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 должен выполнять два основных действия:


  1. Замена TypeAliasDeclarations на InterfaceDeclarations
  2. Преобразование TypeReferences в TypeLiterals

Решение


Вот так выглядит код Visitor-а:


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);
    /*
      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-ы, заставляют меня краснеть от стыда.


Ресурсы, которые мне помогли:


Microsoft/TypeScript


Creating a TypeScript Transformer


TypeScript compiler APIs revisited


AST explorer

Tags:
Hubs:
Total votes 17: ↑17 and ↓0+17
Comments1

Articles