Компиляция TypeScript в нативный бинарник с помощью scriptc
scriptc компилирует TypeScript в нативные бинарники: статическая сборка, динамический движок 620 КБ, проверка coverage и понятные ошибки.
scriptc компилирует обычный TypeScript в самодостаточный нативный исполняемый файл. Статическая сборка не тянет с собой JavaScript-движок — не считая интерпретатора регулярных выражений, который линкуется только в том случае, если ваш код использует регулярки. Настоящий компилятор TypeScript проверяет типы программы, scriptc понижает её до типизированного промежуточного представления, а на выходе получается нативный код.
Если вы поставляете CLI, написанный на TypeScript, вам знаком этот компромисс. Инструмент — это 40 КБ логики. А механизм доставки — это рантайм на 100 МБ, шаг установки и время запуска, которое пользователь замечает.
Интересен здесь не сам бинарник. Другие инструменты тоже его выдают — упаковывая рантайм внутрь. scriptc же по возможности оставляет движок за бортом и явно сообщает, какие части вашей программы он может обработать, а какие нет. В этой статье разбираются три исхода, в которые может попасть любая конструкция, и одна команда, которая покажет, куда попадает ваш собственный код.
Ключевые выводы
- scriptc компилирует тот TypeScript, который вы и так пишете. Не нужно учить диалект, не нужны аннотации, нет замены стандартной библиотеки, а проверка типов выполняется настоящим компилятором TypeScript.
- Статическая компиляция — режим по умолчанию и единственный, пока вы не передадите
--dynamic, который встраивает в бинарник quickjs-ng примерно на 620 КБ. - Всё, что не укладывается ни в один из уровней, останавливает сборку. Вы получаете код
SC, проблемные строки и обычно предложение по переписыванию — вместо бинарника, который работает незаметно неправильно. - Запуск
scriptc coverageдаёт вердикт по каждому выражению: какие части попадают в статический уровень, какие потянут за собой движок, и кодированную диагностику с указанием каждого блокера. - Большинство npm-пакетов поставляются как обычный JavaScript плюс отдельные файлы деклараций, что не даёт статическому уровню типизированного исходника, поэтому реальные деревья зависимостей возвращают встроенный движок в бинарник.
Что такое scriptc и как работает конвейер?
scriptc берёт точку входа .ts, проверяет типы компилятором TypeScript, понижает проверенную программу до типизированного IR и из него генерирует нативный код. В README scriptc LLVM указан как генератор кода по умолчанию, а C сохраняется как постоянный читаемый референсный бэкенд, выбираемый через --backend c, — так что формулировка «TypeScript в C, затем в clang» описывает лишь один из двух путей. Исходник, который вы ему скармливаете, — это тот же исходник, который вы уже запускаете на Node.
Установка — обычная глобальная установка через npm, а для сборки исполняемых файлов на хосте нужен драйвер линковщика:
npm install -g scriptc
Quickstart требует Node 24 или новее для работы компилятора. Для сборки исполняемых файлов также нужен платформенный линковщик и соответствующий SDK или sysroot, а Platform Support уточняет остальное: на поддерживаемых хостах macOS, Linux и Windows уровень LLVM линкует предкомпилированный пакет рантайма, поэтому C-компилятор требуется только для явных C-сборок, запасных путей LLVM и --sanitize. Для вывода исходного кода через --emit=ir|c|llvm не нужно ничего, кроме Node.
Минимальная программа и две команды, которые имеют значение:
// slug.ts
function slug(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
console.log(slug("Compile TypeScript to a Native Binary"));
scriptc run slug.ts
scriptc build slug.ts -o slug && ./slug
scriptc run компилирует и выполняет за один шаг — именно это нужно в цикле watch. scriptc build -o производит артефакт, который вы действительно поставляете.
Уровень 1: статическая компиляция по умолчанию
Статическая компиляция — режим по умолчанию в scriptc и единственный доступный, пока вы явно от него не откажетесь. На главной странице scriptc уровень 1 представлен как повседневный TypeScript: классы и замыкания, async/await, стандартная библиотека и те части Node, к которым обращается большинство программ — fs, path, process и http. Всё это превращается в нативный код, и в бинарнике нет никакого движка.
Подробный охват шире, чем можно подумать по заголовочному списку. Вводная страница делит его на три группы. На стороне языка вы получаете классы с одиночным наследованием и динамической диспетчеризацией, замыкания, захватывающие переменные так же, как в JavaScript, объявления обобщённых функций, разрешаемые мономорфизацией, размеченные объединения, обрабатываемые через собственное сужение типов TypeScript, async/await с точно таким же планированием, как в JavaScript, исключения с finally, деструктуризацию, spread, аксессоры, итераторы и шаблонные литералы. Группа стандартной библиотеки охватывает строки, массивы, Map и Set, JSON, Math, типизированные массивы и иерархию Error. Группа Node включает fs в синхронной и промис-форме, а также path, process, child_process, os, crypto, url/URL, zlib и таймеры, и содержит весь серверный стек: net, http, https, tls, dgram, dns и readline.
Это значит, что компилируется HTTP-сервис, а не только чистая функция:
// server.ts
import http from "node:http";
http.createServer((req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ path: req.url }));
}).listen(3000);
scriptc build server.ts -o server && ./server
Любое перечисление статического охвата устаревает по мере развития компилятора. Changelog отслеживает это от релиза к релизу, и каждый релиз также поставляет машиночитаемый surface-manifest.json со списком языковых конструкций и элементов стандартной библиотеки, которые статический уровень поддерживает в данной версии, со стабильными идентификаторами для каждой записи, чтобы инструменты могли сравнивать два релиза. Этот файл переживёт любой текстовый список, включая этот.
Уровень 2: динамический уровень и его движок на 620 КБ
Передача --dynamic встраивает JavaScript-движок в бинарник, и больше ничто этого не делает. Руководство по зависимостям npm называет результат динамическим островом: встроенный движок примерно на 620 КБ, который выполняет всё, что не может быть статическим, а на практике это JavaScript, поставляемый npm-пакетами, и всё, что средство проверки типизирует как any. Значения валидируются при обратном переходе в статический код. Движок — quickjs-ng.
npm install picocolors
scriptc build cli.ts --dynamic -o cli
Смысл дизайна — в явном согласии. Бинарник scriptc никогда не обрастает движком незаметно; эти 620 КБ — всегда то, что вы запросили сами. Отсюда два следствия. JavaScript пакета попадает в исполняемый файл на этапе сборки, поэтому готовый бинарник самодостаточен и не имеет причин заглядывать в node_modules во время работы. И граница проверяется, а не принимается на веру: файл деклараций, который обещал string, а выдал объект, бросит перехватываемый TypeError вместо порчи памяти в нативном коде, рассчитывавшем на другое.
Уровень 3: отклонение на этапе компиляции
Код, который scriptc не станет компилировать статически и не может направить через динамический уровень, роняет сборку. Обещание, которое главная страница даёт для этого уровня, — что отказ будет читаемым: конкретный код ошибки, проблемные строки и в большинстве случаев подсказка, как их переписать. Ничто не превращается втихую во «почти эквивалентное». Диагностические коды имеют префикс SC, и SC3002 — тот, с которым вы столкнётесь на цели WASI: сокеты и fetch, дочерние процессы, API сигналов и fs.watch — всё это останавливает сборку до этапа линковки, потому что Preview 1 не даёт гостю никакой возможности это сделать.
Именно это трёхстороннее разделение делает остальную часть дизайна достойной серьёзного отношения. Компилятор, который тихо деградировал бы конструкцию до чего-то почти эквивалентного, сделал бы каждое утверждение о производительности и семантике условным. Отказ от генерации — с номером строки и предложением по переписыванию — это то, что делает обещание статического уровня проверяемым.
Как scriptc coverage подскажет, подходит ли ваш код?
scriptc coverage — это способ ответить на вопрос «скомпилируется ли мой код», ничего не мигрируя. Команда проходит по программе выражение за выражением и сообщает, какие из них попадают в статический уровень, каким потребуется движок и что блокирует остальные, с диагностическим кодом для каждого блокирующего места. Запускайте её на своей реальной точке входа, а не на игрушечном файле.
Quickstart прогоняет через команду hello.ts из двух выражений: проанализировано 2 выражения, 2 компилируются статически, 100%, и строка вердикта о том, что у программы нет динамического остатка. Пример в README — уже реальный проект, и там сообщается о 4451 статическом выражении из 4481, то есть 99%. Реалистичный проект выведет процент ниже и список именованных мест. Читайте вывод в три прохода: заголовочный процент говорит, является ли проект кандидатом в принципе; диагностика по каждому месту говорит, что блокирует; а природа каждого блокера говорит, какое исправление применимо.
Блокеры чётко делятся на два вида. Нетипизированный npm-импорт — это не то, что переписывают, а то, с чем соглашаются, и означает он сборку с --dynamic. Слабая типизация в вашем собственном коде обычно исправима:
// forces the dynamic tier: the payload is any
function port(config: any): number {
return config.port + 1;
}
Объявите форму — и та же функция скомпилируется статически:
interface Config { port: number }
function port(config: Config): number {
return config.port + 1;
}
Там, где анализ прерывается рано — на ошибке типов или на барьере импорта, — changelog фиксирует, что coverage теперь печатает ту же диагностику, что и упавшая сборка, с фрагментами кода и всем прочим, а не одну сухую строку сводки. Добавление --dynamic к команде идёт дальше и сообщает, какие места в итоге исполнял бы встроенный движок.
Какие цифры публикует проект?
Главная страница указывает для бинарника hello-world около 320 КБ, время запуска около 4 мс и libSystem как единственную слинкованную библиотеку — против рантайма Node примерно на 120 МБ, которому нужно около 35 мс, чтобы вывести ту же строку. Таблица бенчмарков в README оптимистичнее по той же нагрузке: 170–200 КБ и около 2,4 мс запуска против ~47 мс у Node. Два источника проекта расходятся, так что стоит понимать, откуда взялась конкретная цифра. В любом случае это собственные цифры проекта для hello-world на его первоклассном хосте macOS, а не общее утверждение о вашем приложении.
Относитесь к ним как к нижней границе, а не как к прогнозу. Бинарник, собранный с --dynamic, несёт движок и встроенный JavaScript пакетов, поэтому класс размера меняется. Единственная цифра, которая чисто переносится в вашу собственную оценку, — это 620 КБ на движок, потому что это фиксированная документированная надбавка, которую вы либо принимаете, либо избегаете.
Во что на самом деле обходится внедрение?
scriptc живёт в пространстве имён vercel-labs и всё ещё находится на версии 0.1.x. Обсуждения в сообществе с момента релиза в конце июля 2026 года крутятся именно вокруг этого статуса: наберёт ли проект из Labs те годы сопровождения, которых требует компилятор в вашем конвейере сборки. Репозиторий публикует теггированные npm-релизы и лицензию Apache-2.0, но никаких заявлений о поддержке или SLA к ним не прилагается.
Более острое практическое ограничение — экосистема. Большинство npm-пакетов поставляют скомпилированный JavaScript вместе с отдельными декларациями .d.ts, что не даёт статическому уровню типизированного исходника для компиляции, поэтому такой код выполняется во встроенном движке под --dynamic, и движок едет вместе с вашим бинарником. Пакеты вообще без деклараций деградируют не тихо: они не проходят барьер проверки типов со стандартной ошибкой TypeScript об отсутствующей декларации — ровно так же, как в любом строгом TypeScript-проекте. Остальные шероховатости задокументированы по отдельности, вплоть до деталей вроде того, что scriptc run не пробрасывает дополнительные аргументы CLI в программу, и страницу ограничений стоит прочитать до того, как планировать миграцию.
Честная картина применимости: плотно типизированный CLI или небольшой сервис с малым числом рантайм-зависимостей или вовсе без них — сильный кандидат, а проект с глубоким деревом зависимостей покупает движок на 620 КБ плюс встроенный JavaScript для большей части своего кода. Установите CLI, запустите scriptc coverage на своей точке входа и позвольте проценту и списку блокеров решать — вместо заголовка.
Часто задаваемые вопросы
Нужен ли на машине, где запускается бинарник scriptc, установленный Node.js или clang?
Нет. Всё, что нужно scriptc, требуется только на этапе сборки. Компилятор работает на Node.js 24, а для сборки исполняемых файлов нужен платформенный драйвер линковщика плюс соответствующий SDK или sysroot. На поддерживаемых хостах macOS, Linux и Windows уровень LLVM линкует предкомпилированный пакет рантайма вместо компиляции C, поэтому C-компилятор вроде clang требуется только для явных C-сборок, запасных путей LLVM и сборок с санитайзерами. Самим исполняемым файлам Node не нужен: статическая сборка несёт небольшой нативный рантайм, без Node и без JavaScript-движка, не считая интерпретатора регулярных выражений, который линкуется, когда ваш код использует регулярки. Для вывода исходного кода с целями ir, c и llvm достаточно одного Node.
Может ли scriptc собирать бинарники для Linux или Windows на Mac?
Да. scriptc поддерживает целевые платформы macOS, Linux, Windows и WebAssembly через WASI Preview 1, причём macOS arm64 является первоклассным хостом. Кросс-компиляция через zig — один из путей к бинарникам для Linux и Windows, но у обеих целей есть и собственные нативные помощники и пакеты рантайма, покрывающие Linux x64 и arm64 и Windows x64. Путь WASI управляется переменными окружения SCRIPTC_CC и SCRIPTC_TARGET, установленными в zigcc и wasm32-wasi, а API, отсутствующие в Preview 1 — сокеты, дочерние процессы и наблюдение за файловой системой, — падают до линковки с кодом SC3002.
Что произойдёт, если npm-пакет, работающий во встроенном движке, изменит переданный ему объект?
Статическая сторона никогда не увидит это изменение. В динамической сборке значения копируются через границу, а не разделяются, поэтому всё, что меняет пакет, исполняемый движком, оставляет статический оригинал нетронутым, и всё, что меняет статический код, оставляет нетронутой копию движка. scriptc перечисляет это как одно из своих намеренных отступлений от JavaScript, где обе стороны держали бы один и тот же объект.
Можно ли скомпилировать код npm-зависимостей статически вместо выполнения в движке?
Да, с экспериментальным флагом --npm-static. Вы перечисляете пакеты или передаёте auto, и компилятор пытается вытащить их из встроенного движка и скомпилировать поставляемый ими JavaScript как статические модули программы, типизированные их собственными файлами деклараций. Покрытие высокое, но частичное: места, которые статический компилятор не может взять, откладываются и называются в отчёте, а пакет, отклонённый предварительной проверкой, возвращается в движок с пометкой, не ломая сборку. Запустите coverage, чтобы увидеть, какие из ваших пакетов проходят.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k