Въведение

В първата си публикация в блога бих искал да разгледам проблем, който разреших наскоро с помощта на API на компилатора на TypeScript. Сигурен съм, че нямаше да мога да накарам нещо да работи без помощта на различни блогове и отговори на StackOverflow, така че се чувствах доста егоистично да не пиша за моите знания около мощен, но леко документиран набор от инструменти.

Засегнати теми

Основи на API на компилатора на TypeScript (терминология на парсера, API на трансформатор, многослойна архитектура), AST, модел на посетител, генериране на код.

Предпоставка

@Vaidehi Joshi има страхотна статия за AST, която бих препоръчал да прочетете, ако не сте запознати с концепцията. Нейната серия basecs е прекрасна и трябва да я разгледате.

Проблем, който решавам

Използваме GraphQL в Avero и искахме да добавим известна безопасност на типа около резолверите. Попаднах на graphqlgen, който реши много от тези проблеми, които имах с концепцията си за модели. Не искам да се гмуркам твърде дълбоко в тази тема в тази публикация в блога, но се надявам да напиша нещо в бъдеще, което да се потопи в GQL. tldr е, че моделите представляват върнатата стойност на вашите преобразуватели на заявки (които може да се различават от вашата GQL схема), а в graphqlgen вие свързвате тези модели с интерфейси, използвайки някакъв вид конфигурация (YAML или TypeScript файл с декларации на типове).

По време на работа изпълняваме микроуслуги gRPC и GQL служи най-вече като хубав разклонителен слой за нашите потребители на UI. Вече публикуваме TypeScript интерфейси, които съответстват на нашите прото договори и исках да използвам тези типове в graphqlgen, за да служат като наши модели, но се натъкнах на някои проблеми поради поддръжка за експортиране на типове и с начина, по който нашите TypeScript интерфейси са публикувани (силно пространство от имена, много препратки).

Като всеки добър гражданин с отворен код, първият ми подход беше да използвам вече извършената работа в graphqlgen repo и да се опитам да добавя смислен принос. За да направи своята интроспекция на типа, graphqlgen използва @babel/parser, за да прочете файла TypeScript (в моя случай) и да събере информация за имената на интерфейса и декларациите (полетата на интерфейса).

Всеки път, когато искам да направя нещо с ASTs, веднага отварям astexplorer.net и започвам да си играя. Този инструмент ни позволява да изследваме AST, генерирани от много различни анализатори, включително както @babel/parser, така и анализатора на компилатора на TypeScript. Това ни дава чудесен начин да визуализираме структурите от данни, с които ще работим, и да се запознаете с типовете на AST възли за дадения анализатор.

Нека да разгледаме примерен входен файл и съответния AST с помощта на babel-parser:

Коренът на нашия AST (тип възел на Program) има два израза в тялото си, ImportDeclaration и ExportNamedDeclaration.

Разглеждайки първо нашата Декларация за импортиране, има две свойства, които ни интересуват: източник и спецификатори. Тези възли включват само информация за изходния текст. Например изходната стойност е my_company_protos. Това не ми дава информация за това дали това е относителен файлов път или се отнася до външен модул, така че това е едно нещо, което трябва да реша с помощта на подхода на анализатора.

По подобен начин в нашата ExportNamedDeclaration ни е дадена основна информация за изходния текст. Пространствата от имена усложняват тази структура и тя може да бъде произволно вложена, което добавя все повече и повече QualifiedTypeIdentifier. Това би било още една неудобна ситуация, която трябва да разрешим, ако продължим по този път.

Все още дори не съм стигнал до разрешаване на типове от импортиране! Като се има предвид, че анализаторът и AST са (по дизайн) ограничени до информация в изходния си текст, ще трябва да анализираме всички импортирани файлове, за да имаме тази информация налична в нашия окончателен AST. Но този внос може да има свой собствен внос!

Изглежда, че анализаторът е доста ограничен тук при решаването на нашия проблем без много код, така че нека да направим крачка назад и да помислим отново за проблема.

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

Въведение в TypeScript's TypeChecker

Тъй като решихме, че анализаторът не е достатъчен за събиране на информация за типове за импортирани интерфейси, нека прегледаме как работи „процесът на компилиране“ на TypeScript, за да видим дали можем да направим извод къде да търсим след това.

Тук веднага се откроява една част:

От екземпляр на програмата може да се създаде TypeChecker. TypeChecker е ядрото на системата за типове TypeScript. Това е частта, отговорна за намирането на връзки между символи от различни файлове, присвояване на типове на символи и генериране на всякаква семантична диагностика (т.е. грешки).

Първото нещо, което TypeChecker ще направи, е да консолидира всички символи от различни изходни файлове в един изглед и да изгради една таблица със символи чрез „сливане“ на всички общи символи (напр. пространства от имена, обхващащи множество файлове).

След инициализиране на първоначалното състояние, TypeChecker е готов да отговори на всякакви въпроси относно програмата. Такива „въпроси“ могат да бъдат:

Какъв е символът за този възел?

Какъв е типът на този символ?

Какви символи се виждат в тази част от AST?

Какви са наличните подписи за декларация на функция?

Какви грешки трябва да се докладват за даден файл?

TypeChecker звучи като точно това, от което се нуждаем! Искаме достъп до базовата символна таблица и API, за да можем да отговорим на първите два въпроса: Какъв е символът за този възел? И какъв е типът на този символ? Той дори споменава справяне със сливането на общи символи, така че адресира проблема с пространството на имената, за който говорихме по-рано!

Soooo, как да стигнем до този API?

„Това“ е един от малкото примери, които успях да намеря онлайн, но е достатъчно, за да започнем. Можем да видим, че инструментът за проверка може да бъде достъпен от метод на нашия екземпляр на Program. Разглеждайки използването в този пример, можем да видим методи като checker.getSymbolAtLocation и checker.getTypeOfSymbolAtLocation, което изглежда е поне някакъв вариант на това, от което се нуждаем.

Нека започнем да пишем нашата програма.

ts-node ./src/ts-alias.ts
prints
ImportDeclaration
TypeAliasDeclaration
EndOfFileToken

Тук сме загрижени за декларацията за псевдоним на типа, така че нека актуализираме нашия код, за да се съсредоточи върху това.

TypeScript предоставя защита на типа за всеки съответен тип възел, така че можем да стесним възела до правилния му тип, като използваме тези тук.

След като имаме нашия възел, искаме да се върнем към отговора на двата въпроса, които споменахме по-рано: Какъв е символът за този възел? И какъв е типът на този символ?

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

Нека помислим за поколението.

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

Показахме целта си по-рано… даден TypeScript файл, анализирайте, интроспектирайте и създайте нов TypeScript файл. Сигнатурата на функцията на AST -› AST е невероятно често срещана в програмирането — достатъчно, че екипът на TypeScript пусна „API за персонализирана трансформация“, за да създадете свой собствен!

Нека напишем невероятно прост персонализиран трансформатор, преди да се потопим в нашия първоначален проблем. Благодаря на James Garbutt, че ми даде шаблонната плоча, с която да започна.

Първият ни основен трансформатор ще промени числовите литерали в низови литерали.

Най-важните интерфейси, за които трябва да се притеснявате тук, са Visitor и VisitorResult:

type Visitor = (node: Node) => VisitResult<Node>;
type VisitResult<T extends Node> = T | T[] | undefined;

Нашата цел като автор на персонализиран трансформатор е да напишем този Посетител. Ние рекурсивно посещаваме всеки възел в нашия AST и връщаме VisitResult, който може да бъде един, много или нула AST възли. Можем да се насочим към конкретни възли, които да модифицираме, като оставяме останалите сами.

„Тук“ е етикетиран AST, за да покаже възлите, с които ще работим.

Нашият посетител трябва да се справи с два основни случая:

  1. Заменете TypeAliasDeclarations с InterfaceDeclarations
  2. Разрешете TypeReferences до TypeLiterals

Решение

Ето как изглежда този код на посетителя с минимален CLI:

Бях наистина доволен от това как се оказа моето решение. Това показва силата на добрите абстракции, интелигентния дизайн на компилатора, страхотните инструменти за разработчици (VSCode autocomplete, AST explorer и т.н.) и малко аутсорсинг от опита на други интелигентни хора. Напълно актуализираният изходен код може да бъде намерен тук. Не съм сигурен колко полезно ще бъде това за всеки извън моя тесен случай на употреба, но най-вече исках да покажа силата на инструменталната верига на компилатора на TypeScript, както и да документирам мисловния си процес към уникален проблем, който не бях решавал наистина преди.

Надявам се това да е полезно за всеки, който се опитва да прави подобни неща. Ако се плашите от теми като AST, компилатори и трансформации, надявам се, че ви дадох достатъчно шаблони и връзки към други ресурси, за да започнете. Кодът тук е окончателният ми резултат след седене за продължителни периоди от време за учене. С Github частни репозитории моите първи опити за това, включително всичките 45 // @ts-ignores и ! твърдения във файл от 150 реда, могат да се скрият в сенките на срама.

Ресурси, които ми помогнаха