Одной из интересных возможностей пакета 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.
Результат выполнения скрипта — вывод в консоль строки:
Разберем подробнее код в ./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, который будем использовать для парсинга:
// ./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 удобен в использовании и сильно экономит время для анализа 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 к методу.
ссылка на оригинал статьи https://habr.com/ru/articles/782822/
Добавить комментарий