Одной из интересных возможностей пакета typescript является то, что он содержит API для генерации TypeScript-кода, а также парсер для работы с написанным на TypeScript кодом. Кодогенерация часто используется для автоматического создания типов для работы с http api (типизация тела запроса, ответа, query параметров и тд.). В npm есть модули, генерирующие сервисы для работы с api на основе openapi, graphQl схем и тому подобное, и обычно возможностей существующих модулей хватает для решения большей части задач.

Но иногда возникает потребность расширить имеющийся интерфейс или даже написать свою кастомную реализацию. В этой статье мы рассмотрим основные принципы работы с инструментами для генерации и парсинга typescript кода, а так же некоторые подводные камни, на которые я наткнулся при работе с ним.

Кодогенерация

Рассмотрим для примера генерацию кода с декларацией простого union:

export type Numbers = "one" | "two" | "three";

Для начала сконфигурируем небольшой проект с помощью команды npm init. Затем установим npm пакет typescript командой npm install typescript. Также для дальнейшей работы нам нужно установить зависимость npm install @types/node --save-dev.

Для генерации указанного выше union Numbers понадобится следующий код:

// ./src/index.ts
import * as ts from "typescript";

const file = ts.createSourceFile("source.ts", "", ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });

const one = ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral("one"));
const two = ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral("two"));
const three = ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral("three"));

const decl = ts.factory.createTypeAliasDeclaration(
  [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], // modifiers (export keyword)
  ts.factory.createIdentifier("Numbers"), // name
  undefined, // typeParameters
  ts.factory.createUnionTypeNode([ one, two, three ]), // type
)

const result = printer.printNode(ts.EmitHint.Unspecified, decl, file);
console.log(result);

Для компиляции и запуска этого файла добавим в проектtsconfig.json:

// ./tsconfig.json
{
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "sourceMap": true,
    "target": "esnext",
    "module": "CommonJS",
    "moduleResolution": "node",
    "types": [
      "node"
    ],
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true
  },
  "include": ["./src/**/*.ts"]
}

А также скрипты build и start в package.json:

// ./package.json
"scripts": {
  "build": "tsc",
  "start": "node dist/index.js"
}

Теперь мы можем скомпилировать и выполнить код из ./src/index.ts. Последовательно запускаем команды npm run build, npm run start

Результат выполнения скрипта — вывод в консоль строки:

Кодогенерация union Numbers
Кодогенерация union Numbers

Разберем подробнее код в ./src/index.ts. Вначале импортируется пакет typescript и создается специальный объект принтера, который используется в дальнейшем для форматированного вывода построенной typescript декларации:

const file = ts.createSourceFile("source.ts", "", ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });

// ...

const result = printer.printNode(ts.EmitHint.Unspecified, decl, file);
console.log(result);

Больший интерес представляет код, содержащийся между этими строками:

const one = ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral("one"));
const two = ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral("two"));
const three = ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral("three"));

const decl = ts.factory.createTypeAliasDeclaration(
  [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], // modifiers (export keyword)
  ts.factory.createIdentifier("Numbers"), // name
  undefined, // typeParameters
  ts.factory.createUnionTypeNode([ one, two, three ]), // type
);

Алгоритм последовательно создает литералы для элементов юниона, а затем композирует их в единую декларацию, присваивая модификатор export и имя Numbers. Как видим: ничего сложного, вся сложность заключается только в идентификации сущности и нужного для ее создания конструктора. API typescript предлагает огромное множество функций для создания различных конструкций. В них очень легко запутаться, ибо из названия не всегда очевидно, что создает та или иная функция, и непонятно, что нужно в нее передавать в качестве параметров:

TypeScript api
TypeScript api

Для идентификации названий нужных typescript сущностей можно использовать API для парсинга кода. То есть сначала пишем псевдокод, парсим его средствами typescript и получаем AST (abstract syntax tree), изучаем его и создаем на основе полученных знаний нужную нам конструкцию.

Парсинг typescript

Для демонстрации API парсинга создадим файл с кодом на typescript, который будем использовать для парсинга:

// ./union.ts
export type Numbers = "one" | "two" | "three";

Далее добавим в ./src/index.ts следующий код:

// ./src/index.ts
import * as fs from 'node:fs';
import * as path from 'path';

const schema = ts.createSourceFile(
  'x.ts',
  fs.readFileSync(path.resolve(process.cwd(), './union.ts'), 'utf-8'),
  ts.ScriptTarget.Latest,
  undefined,
);

console.log(schema.statements[0]);

Функция createSourceFile создаст AST-дерево для нашего файла union.ts и в поле statements будет массив ts сущностей, задекларированных в файле. Единственная сущность, находящаяся там — это экспортируемый юнион тип Numbers. Вывод в консоль в конце файла console.log(schema.statements[0]); отобразит структуру его AST:

NodeObject {
  pos: 0,
  end: 46,
  flags: 0,
  modifierFlagsCache: 0,
  transformFlags: 1,
  parent: undefined,
  kind: 265,
  symbol: undefined,
  localSymbol: undefined,
  modifiers: [
    TokenObject {
      pos: 0,
      end: 6,
      flags: 0,
      modifierFlagsCache: 0,
      transformFlags: 0,
      parent: undefined,
      kind: 95
    },
    pos: 0,
    end: 6,
    hasTrailingComma: false,
    transformFlags: 0
  ],
  name: IdentifierObject {
    pos: 11,
    end: 19,
    flags: 0,
    modifierFlagsCache: 0,
    transformFlags: 0,
    parent: undefined,
    kind: 80,
    escapedText: 'Numbers',
    jsDoc: undefined,
    flowNode: undefined,
    symbol: undefined
  },
  typeParameters: undefined,
  type: NodeObject {
    pos: 21,
    end: 45,
    flags: 0,
    modifierFlagsCache: 0,
    transformFlags: 1,
    parent: undefined,
    kind: 192,
    types: [
      [NodeObject],
      [NodeObject],
      [NodeObject],
      pos: 21,
      end: 45,
      hasTrailingComma: false,
      transformFlags: 1
    ]
  },
  jsDoc: undefined,
  locals: undefined,
  nextContainer: undefined
}

Анализ AST

AST удобнее анализировать в REPL-режиме, приостановив исполнение скрипта в дебагере IDE. Если вы пользуетесь VSCode, то настроить дебагер можно, добавив файл .vscode/launch.json со следующим содержимым:

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Run programm",
      "program": "${workspaceFolder}/src/index.ts",
      "preLaunchTask": "npm: build",
      "sourceMaps": true,
      "smartStep": true,
      "internalConsoleOptions": "openOnSessionStart",
      "outFiles": ["${workspaceFolder}/dist/**/*.js"],
      "console": "internalConsole",
      "outputCapture": "std"
    }
  ]
}

Далее установим брейкпоинт на строке с выводом в консоль и запустим дебагер (F5 hotkey):

Затем проанализируем AST в объекте schema.statements[0].

Наибольший интерес в нем на данный момент представляют поля kind, modifiers, name и type. kind — в этом поле хранится значение из enum ts.SyntaxKind, по которому можно определить название сущности, а затем найти необходимый для его создания конструктор в пространстве ts.factory.

Как мы видим, корневой элемент нашего дерева имеет kind === 265, то есть тип TypeAliasDeclaration, и для его создания используется следующая функция в ts.factory:

createTypeAliasDeclaration(
  modifiers: readonly ModifierLike[] | undefined,
  name: string | Identifier,
  typeParameters: readonly TypeParameterDeclaration[] | undefined,
  type: TypeNode
): TypeAliasDeclaration;

, где поля modifiers, name, type, содержат как раз те сущности, которые мы видели при выводе в консоль AST для объекта schema.statements[0]. Проанализировав эти поля в REPL, можно понять, какие объекты нам нужно передавать в конструктор.

Анализ AST в REPL дает нам возможность полностью ознакомиться со структурой AST, но отнимает много времени. Если нам нужно быстро выяснить, как создать определенную сущность в виде AST, можно воспользоваться онлайн-инструментом TypeScript AST Viewer. Если в нем написать создаваемый нами union Numbers, то он выведет нам следующую конструкцию для создания его AST:

TypeScript AST Viewer
TypeScript AST Viewer

Инструмент TypeScript AST Viewer удобен в использовании и сильно экономит время для анализа AST, но есть в нем и небольшие ограничения. Он не отображает jsDoc-комментарии. При работе с ними есть определенные особенности, о которых расскажу в конце статьи.

Работа с AST

Мы посмотрели, как извлекать информацию из AST в REPL, теперь рассмотрим пример, как работать с ним в TypeScript

Предположим, что у нас есть файл со следующим содержимым:

// ./numbers.ts
export interface Numbers {
  one: 1
  two: 2
  three: 3
}

и нам нужно его распарсить и сформировать массив строк со значениями ключей интерфейса Numbers: ['one', 'two', 'three'].

Для этого добавим в ./src/index.ts:

// ./src/index.ts
const numbersSource = ts.createSourceFile(
  'x.ts',
  fs.readFileSync(path.resolve(process.cwd(), './numbers.ts'), 'utf-8'),
  ts.ScriptTarget.Latest,
  undefined,
);

const numbersDecl = numbersSource.statements.find((node) => {
  return ts.isInterfaceDeclaration(node) && node.name.text === 'Numbers';
});

if (!numbersDecl || !ts.isInterfaceDeclaration(numbersDecl)) {
  throw new Error('no interface "Numbers" in file');
}

const numbers = numbersDecl.members.reduce<string[]>((acc, node) => {
  if (ts.isPropertySignature(node) && ts.isIdentifier(node.name)) {
    acc.push(node.name.escapedText.toString())
  }

  return acc
}, [])

console.log(numbers); // [ 'one', 'two', 'three' ]

Взаимодействие с AST в TypeScript не сильно отличается от работы с ним в REPL, за исключением того, что элементы statements являются инстансами NodeObject и ничего не знают о полях members, name и прочих. Для того чтобы обратиться к нужным нам полям, надо сначала проверить, что объект является инстансом конкретного класса, у которого имеются данные поля. Для этих целей в typescript есть огромное количество гардов, например, те, что мы использовали в коде выше: ts.isInterfaceDeclaration, ts.isPropertySignature и ts.isIdentifier.

Особенности работы с jsDoc

При анализе AST в REPL у инстансов NodeObject можно заметить поле jsDoc. Как видно из названия, в нем хранится информация о jsDoc-комментариях и тегах для данного узла дерева.

Парсинг jsDoc

Создадим простой файл с классом и комментарием к методу:

// ./comments.ts
export class Greetings {
  /**
   * приветствует мир
   * @deprecated метод устарел
   */
  hello() {
    console.log('hello world')
  }
}

А затем распарсим его и проанализируем его AST в REPL:

// ./src/index.ts
const commentsSource = ts.createSourceFile(
  'x.ts',
  fs.readFileSync(path.resolve(process.cwd(), './comments.ts'), 'utf-8'),
  ts.ScriptTarget.Latest,
  true, // setParentNodes Важно указать true, если нужно работать с jsDoc комментариями
);

console.log(commentsSource.statements[0]);

Посмотрим, как выглядит AST jsDoc-комментария для метода hello класса Greetings

AST метода Greetings.hello
AST метода Greetings.hello

Как видим, в нем есть информация о тексте комментария, а также массив с тегами, состоящий из одного элемента.

Для получения jsDoc-комментариев и тегов в typescript существуют специальные методы:

// ./src/index.ts
const greetingsClass = commentsSource.statements[0];
if (ts.isClassDeclaration(greetingsClass)) {
  const jsDoc = ts.getJSDocCommentsAndTags(greetingsClass.members[0]);
  console.log(jsDoc[0].comment); // приветствует мир

  const deprecated = ts.getJSDocDeprecatedTag(greetingsClass.members[0])
  console.log(deprecated.comment); // метод устарел
}

Для корректной работы этих методов важно указывать при вызове createSourceFile пятый параметр метода (setParentNodes) равным true.

Генерация кода с jsDoc комментариями

При анализе AST видно, что jsDoc-комментарии хранятся в поле jsDoc у инстансов NodeObject. Но методов по добавлению этого поля к существующему объекту или конструкторов, принимающих jsDoc, в API typescript нет. Данная проблема озвучена в ишьюсе, и вот один из вариантов генерации typescript кода рассмотренного ранее класса Greetings:

// ./src/index.ts
/** комментарий для метода hello */
const comment: any = ts.factory.createJSDocComment('приветствует мир', [
  ts.factory.createJSDocUnknownTag(ts.factory.createIdentifier('deprecated')), // тег @deprecated
])

/** метод hello */
const methodDeclaration = ts.factory.createMethodDeclaration(
  undefined,
  undefined,
  ts.factory.createIdentifier('hello'),
  undefined,
  undefined,
  [],
  undefined,
  ts.factory.createBlock(
    [
      ts.factory.createExpressionStatement(
        ts.factory.createCallExpression(
          ts.factory.createPropertyAccessExpression(
            ts.factory.createIdentifier('console'),
            ts.factory.createIdentifier('log')
          ),
          undefined,
          [ts.factory.createStringLiteral('hello world')]
        )
      ),
    ],
    true
  )
)

/** класс Greetings */
const classDeclaration = ts.factory.createClassDeclaration(
  [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
  'Greetings',
  undefined,
  undefined,
  [comment, methodDeclaration]
)

console.log(printer.printNode(ts.EmitHint.Unspecified, classDeclaration, file))

Вызов console.log в конце отобразит наш класс Greetings с jsDoc комментариями для метода hello:

Кодогенерация класса Greetings с jsDoc комментариями
Кодогенерация класса Greetings с jsDoc комментариями

Решение не очень элегантное, так как приходится добавлять any к объекту комментария, но все же позволяет добавить jsDoc к методу.

Комментарии (4)


  1. yarkov
    27.12.2023 21:01
    +1

    Не хочу вас огорчить, но это не enum


    1. HummerHead87 Автор
      27.12.2023 21:01

      Вы про ts.SyntaxKind?


    1. HummerHead87 Автор
      27.12.2023 21:01
      +1

      а, понял. Вы про Numbers. Да, поторопился. В `typescript` это юнион. Просто enum из openapi схем конвертирую в юнионы, вот и перепутал (


  1. ShADAMoV
    27.12.2023 21:01

    TypeScript пишет TypeScript... Видел я тут один сериальчик...