Зачем вообще компилировать TypeScript?

Есть большой проект, с кодовой базой в два миллиона строк на C++. Ядро на плюсах, поверх него работают несколько UI: десктопный интерфейс, веб и мобильное приложение. В какой-то момент проект упирается сразу в две проблемы. Первая — лицензионные ограничения: новые версии Qt, на которых мог бы жить десктопный интерфейс, становятся недоступны по санкционным причинам. Вторая — скорость разработки: UI давно хотелось писать быстрее.

Возникает логичный вопрос: что, если взять лучшие, максимально автоматизированные инструменты из веба и перенести их в мир нативных приложений без браузера и лишних прослоек? Ключевая цель — обеспечить возможность вызова C++-кода из TypeScript с возвратом результата без промежуточных интерпретаторов.

Меня зовут Владимир Цышнатий @Tsyshnatiy. Я занимаюсь разработкой более 15 лет, мой основной профиль — C++. Помимо этого меня увлекают технологии на стыке разных миров. В том числе идея, лежащая в основе этой статьи: дать возможность писать на TS как на нативном языке.

Интересно узнать, как мы это делали и что получилось? Детали под катом! 


Суть проекта

Наша задача — обеспечить бесшовную интеграцию между TypeScript и C++. Помимо этого требуется новый UI-фреймворк, который заменит Qt и будет покрыт TypeScript-биндингами. Так мы сможем писать пользовательский интерфейс на TypeScript с возможностью прямого взаимодействия с C++-кодом в обе стороны. 

Поэтому проект состоит из двух частей: UI-фреймворк с TypeScript-обвязкой и компилятор TypeScript, который позволяет связать весь код в единый бандл и получить нативное десктопное приложение. Компилятор Typescript и есть тема статьи. Мы назвали его TSNative.

Ниже мы рассмотрим существующие режимы выполнения кода и их ограничения. Затем разберём архитектуру TSNative. В завершение сформулируем основные технические выводы, полученные в ходе реализации.

Как может исполняться код

В упрощённом виде существует несколько базовых подходов: интерпретация, интерпретация с JIT-компиляцией (Just-In-Time) и AOT-компиляция (Ahead-Of-Time).

При JIT-подходе код сначала парсится и обычно преобразуется в промежуточное представление (например, в байткод), после чего выполнится либо он, либо его преобразование в нативный код (собственно JIT). Что именно — зависит от конкретной реализации рантайма. Так работают V8, SpiderMonkey и другие современные движки.

При AOT-компиляции весь код компилируется в машинный код заранее, до запуска программы. Так работают C++, Rust и другие компилируемые языки.

На практике современные движки редко укладываются строго в одну из этих моделей. Показательный пример — V8, использующий гибридный подход. На первом этапе JavaScript парсится и исполняется интерпретатором Ignition, который преобразует исходный код в байткод и сразу начинает его выполнение. 

Если движок обнаруживает «горячие» участки кода, он пытается оптимизировать их с помощью JIT-компиляции через компиляторы Sparkplug и TurboFan (в новых версиях также используется Maglev). В этом случае отдельные фрагменты JavaScript компилируются в более эффективный машинный код и подставляются во время выполнения программы. Такой механизм называется JIT-компиляцией (Just In Time), поскольку компиляция происходит непосредственно в процессе работы приложения.

Реальная внутренняя логика движка, разумеется, сложнее описанной схемы. Однако для дальнейшего рассуждения важно следующее: в V8 компиляция JavaScript служит в первую очередь инструментом оптимизации, а не базовым способом исполнения JS.

Почему JavaScript интерпретируемый ��зык

Однозначного ответа на этот вопрос нет, но можно рассмотреть практическую мотивацию. Если представить, что JavaScript не интерпретируется, загрузка веб-страницы выглядела бы иначе. Браузер получал бы большой объём кода, компилировал его на стороне клиента и только после этого начинал отображать интерфейс. При этом значительная часть этого кода могла бы никогда не выполниться, поскольку пользователь не взаимодействует со всеми элементами страницы.

В серверных и десктопных приложениях ситуация принципиально иная. Все исходники известны заранее, код полностью контролируется разработчиком и может быть скомпилирован и развернут до запуска. Здесь не нужно запускать интерфейс максимально быстро любой ценой.

В такой среде естественным образом возникает идея компиляции. В среднем скомпилированный код работает быстрее интерпретируемого, поскольку компилятор располагает более широким контекстом. Интерпретатор обрабатывает ограниченные фрагменты программы по мере выполнения. Компилятор же анализирует весь код или значительные его части и может применять более агрессивные и глобальные оптимизации.

Исходя из этих предпосылок были сформулированы основные цели TSNative.

Первая — повышение производительности за счёт AOT-компиляции. Использовать JIT в этом случае не обязательно, поскольку приложение полностью контролируется разработчиком и может быть скомпилировано заранее.

Вторая — перенос бизнес-логики и UI-кода с C++ на TypeScript для ускорения разработки и повышения удобства сопровождения.

Третья — возможность повторного использования кода из веб-клиента без его полной переработки.

Примечание: на практике третья цель оказалась труднодостижимой из-за широкого использования JavaScript-вставок и типа anyв реальных проектах. Подробнее об этом в разделе "Выводы".

Теперь рассмотрим, как устроен TSNative и каким образом его архитектура позволяет приблизиться к достижению этих целей.

Погружаемся в TSNative

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

TSNative реализует именно фронтенд компилятора. При этом важно сразу обозначить контекст. Сейчас проект находится в стадии прототипа. Это не production-ready решение, а рабочая демонстрация технического подхода и проверки бизнес-гипотезы.

TSNative принимает исходный код на TypeScript и преобразует его в промежуточное представление LLVM IR. Далее используется стандартный бэкенд LLVM. Компилятор llc выполняет финальную генерацию машинного кода под конкретную целевую платформу. Таким образом, TypeScript-код сначала транслируется в LLVM IR, после чего докомпилируется в нативный бинарный файл.

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

Демонстрация

С точки зрения разработчика окружение выглядит достаточно привычно. Сборка запускается стандартной командой npm run build. При этом сам процесс компиляции по своей природе ближе к классической компиляции C++, чем к типичному пайплайну Node.js.  Отдельной задачей было сохранение совместимости с инструментами, привычными веб-разработчикам.

В результате выполнения полученного бинарного файла возвращается массив значений, например ["Alice", "Bob", "Charlie"]. При этом не используется браузер или JavaScript-движок. Итогом является обычное нативное приложение.

Перейдём к деталям реализации.

В общей архитектуре TSNative можем выделить три ключевых компонента.

Первый компонент — TSNative STD. Это реализация JavaScript runtime, совместимого с TypeScript-типами.В неё входят базовые типы ECMAScript (числа, массивы, объекты), сборка мусора, цикл обработки событий и другие фундаментальные элементы среды выполнения.

Второй компонент — TSNative Declarator. Он отвечает за сопоставление вызовов из TypeScript с реализациями на C++. Например, при вызове функции parseFloat в TypeScript именно благодаря декларации можно определить какая C++-реализация соответствует этому вызову. Для этого используются файлы .d.ts, стандартный механизм TypeScript-деклараций, описывающий классы, функции и их сигнатуры.

Третий компонент — собственно компилятор, реализующий фронтенд. Он выполняет парсинг TypeScript-кода, строит промежуточное представление и генерирует LLVM IR.

Стандартная библиотека. TSNative STD

Начнём со стандартной библиотеки TSNative - TSNative STD.

Внутри TSNative STD выделяется понятие Runtime. В него входит всё, что участвует в выполнении программы во время работы, но не проявляется напрямую в пользовательском коде. Это внутренняя механика среды выполнения и набор предопределенных типов.

class TS_DECLARE Number : public Object
{
public:
    TS_METHOD static Boolean* isNaN(Object* value) noexcept;
    ...
    TS_METHOD TS_SIGNATURE("parseInt(s: string, radix?: number): number")
    static Number* parseInt(String* str, Union* radix) noexcept;

    TS_METHOD static Number* parseFloat(String* str) noexcept;

    TS_METHOD TS_NO_CHECK TS_SIGNATURE("constructor(_: any)")
    Number(double v);

В качестве простого примера можно рассмотреть реализацию класса Number. Он реализован на C++ и снабжён специальными “аннотациями”, такими как TS_DECLARE, TS_METHOD и TS_SIGNATURE. Эти макросы напрямую связаны с TypeScript-частью системы и используются для сопоставления нативной реализации с моделью языка.

Важно отметить, что имена и сигнатуры методов соответствуют официальной документации TypeScript/ECMAScript. Поведение реализовано в соответствии со спецификацией ECMAScript, однако полное покрытие всех edge cases требует дополнительного тестирования. Это сделано намеренно. Цель TSNative не в создании нового языка или альтернативной стандартной библиотеки, а в переносе существующей модели TypeScript в нативную среду с минимальными расхождениями в поведении и API.

Строго говоря, на этом функциональная часть TSNative STD заканчивается. Всё остальное представляет собой аккуратную работу по связыванию TypeScript-кода с нативной реализацией и последующей генерации исполняемого бинарного файла.

TSNative Declarator

Следующий компонент — TSNative Declarator. Он отвечает за обеспечение совместимости C++-реализаций с TypeScript-кодом. Его задача — связать C++-код и мир TypeScript с помощью корректных деклараций.

class TS_DECLARE Number : public Object
{
public:
    TS_METHOD static Boolean* isNaN(Object* value) noexcept;
    ...
    TS_METHOD TS_SIGNATURE("parseInt(s: string, radix?: number): number")
    static Number* parseInt(String* str, Union* radix) noexcept;

    TS_METHOD static Number* parseFloat(String* str) noexcept;

    TS_METHOD TS_NO_CHECK TS_SIGNATURE("constructor(_: any)")
    Number(double v);

Снова обратимся к классу Number. В его описании можно увидеть “аннотацию” TS_SIGNATURE, в которой указана сигнатура parseInt. Важно, что эта сигнатура задаётся в виде строкового литерала ровно в том виде, в каком она выглядела бы в TypeScript. Для декларатора наличие TS_SIGNATURE означает, что указанную сигнатуру необходимо напрямую перенести в декларацию без изменений.

Если же используется аннотация TS_METHOD, декларация формируется автоматически на основе информации, извлечённой из C++-кода. Таким образом, декларатор выполняет роль вспомогательного слоя, который либо переносит явно заданные сигнатуры, либо строит сам.

declare class Number {
    static isNaN(value: Object): boolean;
    static isFinite(value: Object): boolean;
    static isInteger(value: Object): boolean;
    static isSafeInteger(value: Object): boolean;
    static parseInt(s: string, radix?: number): number;
    static parseFloat(str: string): number;
    constructor(_: any);
}

На выходе формируется валидный TypeScript-код в виде .d.ts-файлов как в примере выше.

TSNative Declarator принимает исходный C++-код и парсит его, формируя абстрактное синтаксическое дерево С++. Из этого дерева извлекаются имена классов, методы и аннотации, относящиеся к TypeScript. Далее вся собранная информация передаётся в генератор, который формирует итоговые .d.ts-файлы.

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

Теперь самая интересная часть.

Компилятор

Важно понимать: TSNative компилирует лишь ограниченное подмножество TypeScript, ориентированное в основном на возможности ECMA-2016, причём даже этот стандарт реализован не полностью. Многие привычные фронтенд-паттерны — динамические импорты, декораторы, namespace, enum как runtime-объекты — либо работают иначе, либо вовсе не поддерживаются.

Прежде чем переходить к внутреннему устройству компилятора, необходимо определить основные сущности, с которыми он работает. В данном случае ключевыми являются LLVM и его промежуточное представление LLVM IR.

int fibonacci(int n) {
    if (n <= 1) {
        return n;
    }
    else {
        return fibonacci(n - 1) + fibonacci(n - 2);
    }
}

В качестве примера можно рассмотреть рекурсивную реализацию функции Фибоначчи на C++ и соответствующий ей код на LLVM IR.

define i32 @"fibonacci"(i32 %"n") {
entry:
  %"cond" = icmp sle i32 %"n", 1
  br i1 %"cond", label %"then", label %"else"
then:
  ret i32 %"n"
else:
  %"n_minus_1" = sub i32 %"n", 1
  %"fib_n_minus_1" = call i32 @"fibonacci"(i32 %"n_minus_1")
  %"n_minus_2" = sub i32 %"n", 2
  %"fib_n_minus_2" = call i32 @"fibonacci"(i32 %"n_minus_2")
  %"sum" = add i32 %"fib_n_minus_1", %"fib_n_minus_2"
  ret i32 %"sum"
}

На первый взгляд LLVM IR выглядит малочитаемым и визуально перегруженным. Однако при более внимательном рассмотрении в нём можно выделить привычные элементы. Присутствуют определения функций. Есть вызовы функций и возврат значений. Локальные переменные обозначаются с помощью символа процента, например %n или %n_minus_1. Используются типы данных: i1 для булевых значений и i32 для целых чисел. Управление потоком выполнения реализовано через условные переходы br. При истинности условия управление во время выполнения кода передаётся в блок then, иначе - else.

Важно понимать, что LLVM IR требует соблюдения формы SSA (single static assignment). Согласно этому требованию каждой переменной значение может быть присвоено только один раз. В местах слияния потоков управления (например, после if-else) используются специальные phi-инструкции для выбора нужного значения.

В корректном IR невозможно встретить одну и ту же переменную слева от операции присваивания дважды. Такая форма существенно упрощает работу бэкенда компилятора. Ему не требуется анализировать время жизни переменных, так как каждая из них создаётся единственным присваиванием. Кроме того, в SSA-форме значительно проще устранять мёртвый код. Языки, не использующие SSA напрямую, такие как C++, могут быть транслированы в LLVM IR с приведением к этой форме.

В контексте TSNative компилятор принимает на вход код на TypeScript и на выходе генерирует LLVM IR. Для этого необходимо получить дерево разбора TypeScript-кода. Поскольку TypeScript имеет открытую реализацию от Microsoft, можно использовать готовый компонент, отвечающий за парсинг и построение AST. Этот компонент предоставляет API на TypeScript и покрыт соответствующими биндингами.

TypeScript AST Viewer позволяет увидеть исходную функцию, структуру синтаксического дерева и свойства выбранных узлов онлайн.

Здесь возникает важный архитектурный момент. Существует фреймворк, который умеет разбирать TypeScript и строить AST, но его API предоставляется на TypeScript. Это означает, что для прямого использования этого фреймворка сам компилятор также должен быть написан на TypeScript. В результате компилятор запускается в среде Node.js, обрабатывает исходный TypeScript-код, строит AST и на его основе генерирует LLVM IR.

Таким образом, компилятор выполняется как TypeScript-приложение под Node.js, формирует LLVM IR, после чего этот IR докомпилируется стандартными средствами LLVM в нативный исполняемый файл.

Выбор такого решения был осознанным. Мы не ставили целью реализовывать собственный лексер, парсер и построитель AST для TypeScript. Использование существующей реализации позволило существенно сократить объём работы и сосредоточиться на генерации IR и интеграции с нативным кодом.

Верхнеуровневый взгляд на архитектуру компилятора

Рассмотрим простой пример: выражение let c = a + b. На первый взгляд оно выглядит тривиальным, однако на практике a и b могут быть не значениями, а результатами вычислений. Например, выражение может выглядеть как let c = foo + bar. В этом случае компилятору необходимо сначала сгенерировать LLVM IR для вызова foo, получить результат, затем сгенерировать IR для bar и лишь после этого выполнить операцию сложения.

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

export class ExpressionHandlerChain {
  constructor(generator: LLVMGenerator) {
    const noop = new NoopHandler(generator);
    this.generator = generator;

    noop
      .setNext(new AccessHandler(generator))
      .setNext(new ArithmeticHandler(generator))
      .setNext(new AssignmentHandler(generator))
      .setNext(new BitwiseHandler(generator))
      .setNext(new CastHandler(generator))
      .setNext(new ComparisonHandler(generator))
      .setNext(new FunctionHandler(generator))
      ...

Для обработки выражений используются специализированные обработчики, такие как AccessHandler, ArithmeticHandler, AssignmentHandler и другие. Каждый обработчик отвечает за свой тип токена. Например, если обработчик обнаруживает оператор сложения, он определяет, что перед ним бинарное выражение, и берёт на себя генерацию соответствующего LLVM IR.

Для узлов синтаксического дерева, таких как блоки, функции и ветвления, используется отдельная цепочка обработчиков.

Обработка арифметических выражений

Рассмотрим работу ArithmeticHandler: его задача заключается в вычислении левого и правого операндов, а затем генерации LLVM IR, который отвечает собственно за сложение. За создание LLVM IR отвечает объект generator.

export class ArithmeticHandler extends AbstractExpressionHandler {
  handle(expression: ts.Expression, env?: Environment): LLVMValue | undefined {
    if (ts.isBinaryExpression(expression) && this.canHandle(expression)) {

      const left = this.generator.handleExpression(expression.left, env);
      const right = this.generator.handleExpression(expression.right, env);

      switch (expression.operatorToken.kind) {
        case ts.SyntaxKind.PlusToken:
          return left.createAdd(right);

Обработчик вызывает handleExpression для левой части выражения. Управление передаётся по цепочке обработчиков до тех пор, пока не будет сгенерирован IR, вычисляющий соответствующее выражение. Если значение уже вычислено, оно используется напрямую. Аналогичным образом обрабатывается правая часть выражения. После этого генерируется IR-код, соответствующий операции сложения. В зависимости от сложности выражения цепочка обработки может быть как очень короткой, так и достаточно глубокой.

В результате формируется LLVM IR, который отражает структуру исходного выражения.

Получившийся LLVM IR важно уметь корректно интерпретировать. Например, если известно, что в исходном коде присутствует выражение a + b и требуется найти его результат, имеет смысл читать IR снизу вверх. В конце IR можно обнаружить вызов функции вроде @ZNK6Number3addEPS, который и соответствует операции сложения. Поскольку в рассматриваемом коде присутствует только один оператор + с двумя аргументами, этот вызов легко идентифицировать.

При анализе IR важно помнить, что переменные в LLVM не являются идентификаторами в привычном смысле. Значения вроде %496также представляют собой переменные. Если проследить их происхождение, можно увидеть, что %496 это разыменованное значение %425. Переменная %425, в свою очередь, ссылка, в которую ранее через инструкцию store было записано значение %493. А %493 является результатом вызова конструктора числа с одним из аргументом -%492 .

Также можно проследить происхождение второго аргумента и, в целом, вручную разобрать структуру IR. Этот процесс требует определённой подготовки, поскольку LLVM IR не слишком приспособлен для чтения человеком. Его основная цель заключается в предоставлении универсального промежуточного представления, на основе которого могут быть реализованы другие языки программирования. LLVM IR используется, в частности, в Clang, Rust, Kotlin Native и других.

Инкапсуляция сложной логики

Возникает закономерный вопрос о том, как реализуется более сложная логика, например механизмы наследования. Один из возможных подходов заключается в предварительной реализации такой логики на C++ или другом языке и последующей вставке готового символа в IR. В этом случае в IR присутствует лишь один вызов, за которым скрывается сложная нативная реализация. В качестве примера можно рассмотреть оператор in, предназначенный для проверки наличия элемента в структуре данных. Для него реализован OperatorInHandler.

export class OperatorInHandler extends AbstractExpressionHandler { 
  handle(expression: ts.Expression, env?: Environment): LLVMValue | undefined {
    if (ts.isBinaryExpression(expression) && this.canHandle(expression)) {

      const left = this.generator.handleExpression(expression.left, env);
      const right = this.generator.handleExpression(expression.right, env);

      if (expression.operatorToken.kind === ts.SyntaxKind.InKeyword) {
          return this.generator.ts.obj.createOperatorIn(right, left);
      }

Он, как и другие обработчики, вычисляет левый и правый операнды, после чего вызывает метод createOperatorIn, который формирует соответствующий IR-код и добавляет его в результирующее представление.

 createOperatorIn(thisValue: LLVMValue, key: LLVMValue) {
    if (!this.operatorInFn) {
      this.operatorInFn = this.initOperatorInFn();
    }

    const thisUntyped = this.generator.builder.asVoidStar(thisValue);
    return this.generator.builder.createSafeCall(
this.operatorInFn,
[thisUntyped, key]);
  }

Внутри этого вызова заложена логика, отвечающая за поиск реализации оператора. Если соответствующая функция “не найдена”, она “создаётся”, после чего формируется вызов с необходимыми аргументами. Что значит “не найдена” и “создается” — будет ясно чуть позже.

bool ObjectPrivate::operatorIn(const std::string& key) const {
    if (has(key)) {
        return true;
    }
    else if (has(superKeyCpp)) {
        auto* superObject = get(superKeyCpp);
        return superObject->operatorIn(key);
    }
    return false;
}

На стороне C++ оператор in реализован с достаточно сложной логикой. Он выполняет поиск ключа в текущем объекте и, при необходимости, рекурсивно поднимается по иерархии родительских объектов. Однако вызов в LLVM IR выглядит значительно проще.

На уровне IR присутствует вызов operatorIn, за которым скрыта вся логика поиска ключа. При этом в IR отсутствует развёрнутая последовательность поиска ключа, что существенно упрощает анализ IR.

На текущем этапе удалось реализовать преобразование TypeScript-кода в LLVM IR и последующую генерацию нативного бинарного файла с использованием LLVM. По сути, TypeScript и C++ были уравнены в правах. Поток компиляции C++ через Clang и поток компиляции TypeScript через TSNative оказываются концептуально идентичными и сходятся на уровне LLVM IR.

Как всё это работает вместе

На этом этапе закономерно возникает ряд вопросов. Например, если в исходном коде используется вызов parseFloat, откуда компилятор знает, какой именно символ необходимо подставить в LLVM IR?

Механизм сопоставления устроен следующим образом. С одной стороны, имеются сгенерированные декларации, полученные с помощью TSNative Declarator. Это файлы .d.ts, описывающие API. С другой стороны, существует C++-библиотека TSNative STD, в ко��орой реализована соответствующая функциональность. Из этой библиотеки извлекаются символы, которые затем используются при генерации LLVM IR.

В среде Linux для этого применяется утилита nm. Она анализирует бинарную библиотеку и выводит таблицу реализованных в ней символов, по сути формируя соответствие «адрес → имя». Имя может быть mangled, нам нужно и то, и другое. Обычное имя нужно для поиска, а mangled — для вставки в IR. Обнаружив в TypeScript-коде вызов parseFloat, компилятор находит соответствующий символ в этой таблице и вставляет нужный вызов в LLVM IR. Таким образом осуществляется сопоставление TypeScript-вызовов с скомпилированными C++-символами.

Важно, что этот механизм не ограничен стандартной библиотекой TSNative STD. Разработчик может написать собственную C++-библиотеку, сгенерировать для неё корректные .d.ts-декларации и подключить её к компилятору. При корректно описанных сигнатурах символы будут найдены и связаны тем же самым способом.

Это открывает возможность внедрения высокопроизводительных компонентов в TypeScript-код. Например, можно реализовать на C++ вычислительно тяжёлые алгоритмы, такие как параллельные графовые вычисления, описать их API в .d.ts и вызывать их напрямую из TypeScript. С точки зрения пользователя это будет выглядеть как обычный TS-код, тогда как фактическое выполнение будет происходить в нативной среде.

При этом TypeScript-декларации используются не только для типизации. Они также участвуют в организации рантайма. Когда в TypeScript-коде вызываются setTimeout, создаются Promise или используются другие асинхронные примитивы, возникает вопрос о том, какая инфраструктура обрабатывает эти вызовы.

В случае Node.js эту роль выполняет V8, который инициализирует рантайм, event loop и связанные механизмы. В TSNative V8 отсутствует, используется собственный стек исполнения. Это значит, что инициализация рантайма должна выполняться вручную, включая сборщик мусора, event loop, управление памятью и базовые примитивы.

Ключевая архитектурная идея заключается в том, что точка входа приложения располагается не на стороне TypeScript, а на стороне C++.

C++-точка входа инициализирует рантайм TSNative через специальную функцию init. TypeScript-код при этом компилируется не в самостоятельный исполняемый файл, а в статическую библиотеку, которая затем линкуется с C++ executable. Эта библиотека содержит ровно одну экспортируемую функцию — tsmain.

На этапе линковки объявляется extern "C" tsmain, благодаря чему линковщик ищет ее символ в слинкованном в статическую библиотеку TS-коде самостоятельно.  А далее на этапе выполнения происходит вызов result = tsmain().

В итоге архитектура выглядит следующим образом. Существует C++-точка входа, которая инициализирует рантайм. TypeScript-код компилируется в LLVM IR, затем в нативный код и оказывается внутри функции tsmain. После этого C++-код просто вызывает её. Под Runtime::init скрывается инициализация сборщика мусора, event loop’а и вся инфраструктура выполнения. Под tsmain — весь TypeScript-код, переданный компилятору.

Выводы

Разработку TSNative мы начали в 2018 году с ориентацией на стандарты TypeScript примерно 2016 года. Изначально проект задумывался как исследовательский и служил проверкой гипотезы: можно ли собрать TypeScript в нативный бинарный файл без интерпретации.

На текущий момент реализована поддержка стрелочных функций, базового сборщика мусора, шаблонов, циклов, условий, промисов, таймаутов и интервалов. Работают интерфейсы, union-типы, массивы и отображения. Также была доведена до рабочего состояния C++-интеграция, причём сам компилятор активно использует этот же механизм взаимодействия.

Главное преимущество TSNative в наличии полноценного AOT-компилятора для TypeScript. Все исходники компилируются в нативный бинарный файл без интерпретации, без использования V8 и без промежуточных рантаймов. Поток компиляции сопоставим с тем, как работают C++, Rust и другие компилируемые языки. Удалось реализовать двустороннюю совместимость. TypeScript-код может вызывать C++, а C++-код — TypeScript, с учётом требований к линковке, ABI и инициализации рантайма.

При этом переиспользовать существующий веб-код оказалось сложно, так как на практике чисто TypeScript-проекты во фронтенде встречаются редко. Часто используются фрагменты JavaScript, для которых применяется другое дерево разбора. Отсутствие типов и различия в синтаксисе делают такой код несовместимым с текущей реализацией компилятора.

Кроме того, не была реализована поддержка типа any. Фактически это означает потерю совместимости с большинством сторонних JavaScript-библиотек. Этот опыт стал ключевым выводом проекта. С практической точки зрения более перспективным направлением было бы компилировать JavaScript, а не TypeScript.

Репозиторий TSNative доступен здесь. Мы будем рады ответить на вопросы в комментариях и в Telegram-чате. Для тех, кому интересно глубже погрузиться в детали реализации, также есть подкаст, посвящённый в том числе, проекту.

Отдельно хочется поблагодарить всех, кто участвовал в разработке. Спасибо идейному вдохновителю Андрею Шубину, команде инженеров, работавших над проектом, и Роману Маковскому и Юлии Трубенок за вклад в реализацию.