Одной из интересных возможностей пакета typescript является то, что он содержит API для генерации TypeScript-кода, а также парсер для работы с написанным на TypeScript кодом. Кодогенерация часто используется для автоматического создания типов для работы с http api (типизация тела запроса, ответа, query параметров и тд.). В npm есть модули, генерирующие сервисы для работы с api на основе openapi, graphQl схем и тому подобное, и обычно возможностей существующих модулей хватает для решения большей части задач.
Но иногда возникает потребность расширить имеющийся интерфейс или даже написать свою кастомную реализацию. В этой статье мы рассмотрим основные принципы работы с инструментами для генерации и парсинга typescript
кода, а так же некоторые подводные камни, на которые я наткнулся при работе с ним.
Кодогенерация
Рассмотрим для примера генерацию кода с декларацией простого enum
:
export type Numbers = "one" | "two" | "three";
Для начала сконфигурируем небольшой проект с помощью команды npm init
. Затем установим npm пакет typescript
командой npm install typescript
. Также для дальнейшей работы нам нужно установить зависимость npm install @types/node --save-dev
.
Для генерации указанного выше enum 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
.
Результат выполнения скрипта — вывод в консоль строки:
Разберем подробнее код в ./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
и получаем AST (abstract syntax tree), изучаем его и создаем на основе полученных знаний нужную нам конструкцию.
Парсинг typescript
Для демонстрации API парсинга создадим файл с кодом на typescript, который будем использовать для парсинга:
// ./enum.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(), './enum.ts'), 'utf-8'),
ts.ScriptTarget.Latest,
undefined,
);
console.log(schema.statements[0]);
Функция createSourceFile
создаст AST-дерево для нашего файла enum.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. Если в нем написать создаваемый нами enum Numbers, то он выведет нам следующую конструкцию для создания его AST:
Инструмент 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
Как видим, в нем есть информация о тексте комментария, а также массив с тегами, состоящий из одного элемента.
Для получения 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
:
Решение не очень элегантное, так как приходится добавлять any
к объекту комментария, но все же позволяет добавить jsDoc
к методу.