Compile TypeScript to native binaries with Vercel Labs' scriptc: a hands-on tutorial
What if scriptc build app.ts -o app gave you a real 56KB native binary — no Node, no bundled runtime, no JavaScript engine at all? That is what Vercel Labs' scriptc does: it type-checks your TypeScript with the real tsc, lowers it to typed IR and then to readable C or LLVM IR, and links a self-contained executable. It is at #6 on GitHub trending this week with 5,473 stars — and I verified every claim below on a real Linux machine: install, coverage reports, every --emit stage, a hand-linked native binary, a fib(32) benchmark against Node, a native HTTP server, and the --dynamic lane for npm packages.

Why this is blowing up now
scriptc (vercel-labs/scriptc, Apache-2.0) is a Vercel Labs experiment that compiles TypeScript and JavaScript to typed IR, readable C, textual LLVM IR, native assembly and objects, native executables, and WebAssembly modules — with no Node.js and no JavaScript engine in the output. It landed on GitHub on 2026-07-22, and this morning it sits at #6 on GitHub's daily trending list with 5,473 stars and 142 forks, 759 commits and 44 releases deep, pushed today. Developers are passing it around because it does something the ecosystem has wanted for a decade: turn a TypeScript file into a real, self-contained binary you can ship like any C program.
The trick is how it handles the boundary between static and dynamic. It uses the TypeScript compiler itself for parsing and type checking, lowers typed code to native machine code via LLVM, and — for code that genuinely cannot compile statically, like npm packages or any-typed code — offers a --dynamic lane that embeds quickjs-ng explicitly. You always know which lane your code took, because scriptc coverage tells you statement by statement.

What you'll need
- Node.js 24 or newer — the compiler runs on Node (check with
node --version; I used v24.20.0). Node is only needed to run the compiler; the binaries it produces need no Node. - A linker driver on Linux — the one-step executable build invokes a clang-compatible linker. macOS 15+ on arm64 gets a bundled helper; on Linux install clang (
apt install clang) or setSCRIPTC_LINKERto your clang-compatible driver. The sandbox I tested in had no clang, so I linked manually with gcc via the compiler's own recipe — I'll show that exact recipe as a bonus step. - No account, no API key, no GPU, no model downloads. The whole stack is one npm package.
- About 25 minutes.
Step 1 — Install the compiler
npm install -g scriptc
scriptc --version # expect: 0.1.7 (or newer)
My install pulled 11 packages and finished in under 30 seconds. Note the banner in scriptc --help: this is explicitly labeled experimental. The README says so too. Treat what follows as a guided tour of where the project stands today, not a production build system.
Step 2 — Your first compile, and the coverage report
Create hello.ts:
const who = process.argv.length > 2 ? process.argv[2] : "world";
console.log(`hello, ${who}`);
Before building anything, ask scriptc how much of your program can compile statically:
scriptc coverage hello.ts
Expected output (exactly what I saw):
scriptc coverage /path/to/hello.ts
statements analyzed 2
compile statically 2 (100%)
fully static — this program has no dynamic remainder.
This per-statement honesty is the feature that sold me. You don't guess whether your code "fits" the native model — the compiler tells you, and gives a coded diagnostic for every site that doesn't. I even threw it a deliberately awkward case — a computed method call on an any-typed value:
const fnName = process.argv[2] ?? "toUpperCase";
const s: any = "hello";
console.log(s[fnName]());
That also came back 100% static. The typed-IR lowering handles more dynamicism than the README's warnings suggest; when something truly can't go static, you get a diagnostic rather than a silent fallback.
Step 3 — Inspect every stage of the pipeline
The --emit flags need only Node — no compiler, no linker — which makes them the best way to learn what scriptc actually produces:
scriptc build hello.ts --emit=ir # typed IR → .scriptc/hello.ir.json
scriptc build hello.ts --emit=c # readable C → .scriptc/hello.c
scriptc build hello.ts --emit=llvm # LLVM IR → .scriptc/hello.ll
scriptc build hello.ts --emit=asm # assembly → .scriptc/hello.s
scriptc build hello.ts --emit=obj # object → .scriptc/hello.o
On my run, --emit=obj produced a 9,556-byte hello.ll and a 5,544-byte ELF object hello.o. Different output kinds accumulate in .scriptc/; rebuilding one kind just updates its file.
The C output is worth opening. The compiler generated readable, inspectable C against its runtime header — string literals as static structs with reference counts, union types as tagged structs with switch-based narrowing:
/* Generated by scriptc from hello.ts. Do not edit. */
#include "scr_runtime.h"
static struct { size_t rc; size_t len; size_t cap; char data[8]; } sc_lit_1 =
{ SIZE_MAX, 7, 7, "hello, " };
static ScrStr *sc_us_0(ScrUnion *v) { /* ToString u0 */
switch (v->tag) {
case 0: return scr_str_retain((ScrStr *)scr_union_peek(v));
case 1: return scr_str_retain((ScrStr *)&sc_lit_2);
...
}
}
The --backend flag controls this code generator: llvm is the default that ships, and c emits the readable C for inspection, with the README stating program behavior is identical either way.
Step 4 — Build a real native binary
The one-step build is:
scriptc build hello.ts -o hello
./hello Pax
# expect: hello, Pax
Here is the honest part. In my test sandbox there was no clang, and the one-step build failed loudly and precisely: spawn clang ENOENT. Setting SCRIPTC_LINKER=cc also failed — scriptc passes clang-style -target flags, which gcc rejects. So on Linux you genuinely need a clang-compatible linker driver installed; the compiler will not silently paper over it.
But that failure handed me the better tutorial. --print=native-link-info emits a versioned JSON recipe for linking your object by hand — target triple, entry symbol, the exact runtime source list for your program, and system libraries:
scriptc build hello.ts --print=native-link-info
The recipe for hello.ts (schema scriptc.native-link-info.v1, target linux-x64-gnu) listed 21 runtime sources under @scriptc/runtime/src — scr_string.c, scr_console.c, scr_json.c, and so on — compiled with -std=c11 -D_GNU_SOURCE -pthread -O2 -ffunction-sections -fdata-sections -fno-math-errno -fno-strict-aliasing. I compiled exactly those with gcc and linked:
RT=/usr/lib/node_modules/scriptc/node_modules/@scriptc/runtime
for f in scr_number scr_string scr_array scr_bytes scr_bytes_io scr_map \
scr_closure scr_ffi scr_object scr_union scr_exception scr_error \
scr_console scr_lib scr_path scr_url scr_json scr_async \
scr_crypto_async scr_child scr_cycle; do
gcc -std=c11 -D_GNU_SOURCE -pthread -O2 -ffunction-sections -fdata-sections \
-fno-math-errno -fno-strict-aliasing -I$RT/src \
-c $RT/src/$f.c -o rt/$f.o
done
gcc -pthread .scriptc/hello.o rt/*.o -lm -Wl,--gc-sections -o hello
Result: a 56,744-byte ELF binary. ./hello printed hello, world, ./hello Pax printed hello, Pax. ldd shows it links only libc and libm — no Node, no V8, no 80MB runtime. You could even compile the same object on a machine where clang exists and get there in one command; the recipe just proves there's no magic.

Step 5 — Benchmark: Node vs the native binary
Write a compute-bound program, fib.ts:
function fib(n: number): number {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
const t0 = Date.now();
console.log(fib(32));
console.log(`took ${Date.now() - t0}ms`);
Compile and compare (my manual-link recipe from Step 4, same flags):
node fib.ts # 2178309 / took 200ms
./fib # 2178309 / took 43ms
Same answer, ~4.6x faster, in a 42,096-byte binary. This is a microbenchmark, not a systems paper — startup-dominated or I/O-bound scripts will look different — but it shows what the LLVM tier buys you on hot code: the recursive calls and integer arithmetic compile to straight machine code instead of running through an interpreter and JIT warmup.
Step 6 — A real end-to-end: a native HTTP server
scriptc ships a native runtime for supported Node APIs, including node:http (per the README). Write server.ts:
import { createServer } from "node:http";
const server = createServer((req, res) => {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ path: req.url }));
});
server.listen(8080, () => {
console.log("listening on http://localhost:8080");
});
Coverage first — all 5 statements static:
scriptc coverage server.ts
# statements analyzed 5
# compile statically 5 (100%)
# fully static — this program has no dynamic remainder.
The link recipe for this program is per-program: it adds six more runtime sources the hello recipe didn't need — scr_net.c, scr_http.c, and the event-loop backends (scr_loop_epoll.c on Linux, plus kqueue/WSA variants). After compiling those six extras and linking:
./server &
curl -s http://localhost:8080/api/users
# {"path":"/api/users"}
A TypeScript file, compiled to a native binary, serving HTTP with no Node anywhere in the process. That's the whole pitch, working.
Step 7 — When code can't go static: the --dynamic lane
npm packages can't compile to native code, so scriptc embeds them in the executable via an embedded quickjs-ng engine — the result doesn't read node_modules at runtime. Try it:
npm install picocolors
import pc from "picocolors";
console.log(pc.green("hello from scriptc"));
scriptc coverage cli.ts --dynamic
statements analyzed 1
compile statically 0 (0%)
compile dynamically 1 (100%) (island sites — the embedded engine runs them)
builds with --dynamic — no remaining blockers (the island sites above run in the embedded engine).
--emit=obj on this file produced a 6,280-byte object. The terminology is precise: dynamic code becomes "island sites" that the embedded engine runs. Note the honest tradeoff the README states: you get a self-contained binary, but the dynamic islands still carry interpreter overhead. Use coverage --dynamic before building to see exactly which statements land where.
Limits and gotchas, verified
- It's experimental — and says so. The
--helpbanner reads "TypeScript/JavaScript to native and WebAssembly executables (experimental)". The object ABI is marked"stability": "experimental", "compatibility": "exact-runtime-version"in the link recipe: don't mix objects across compiler versions. - Linux needs a clang-compatible linker for the one-step build. Verified: no clang →
spawn clang ENOENT; gcc asSCRIPTC_LINKERfails on clang's-targetflag.apt install clang(or the gcc recipe in Step 4) is the fix. macOS 15+ arm64 uses a bundled helper and precompiled runtime pack, per the README. - WASI needs Zig, and networking doesn't cross over. The README's WASI path uses
SCRIPTC_CC=zigccwithSCRIPTC_TARGET=wasm32-wasi, and APIs like network sockets orfetchfail before linking with diagnosticSC3002— portable WASI Preview 1 has no sockets to give them. - The stdlib is a subset. Node APIs that "compile to the native runtime" are the supported set; the coverage diagnostics are how you discover the boundary.
scriptc coveragebeforescriptc buildshould be muscle memory. - One surprise in the good direction: my
any-typed computed method call compiled 100% statically. The typed-IR inference is more capable than the README's--dynamicwarnings imply — but when inference gives up, the diagnostic tells you exactly where.
When to use this vs the alternatives
| Tool | What you get | When it wins |
|---|---|---|
| scriptc | True native binary (or WASI module) from TS, no runtime | Shipping tiny CLIs/daemons from a TypeScript codebase; wanting to inspect the generated C |
| Bun single-file executable | Binary embedding Bun's runtime | Full Node-API compatibility matters more than binary size |
| pkg / Node SEA | Binary embedding Node | Existing Node apps you want to distribute as one file, unchanged |
| Rust / Go | Native binary from a systems language | Maximum control and ecosystem maturity; rewriting in TS isn't an option |
| esbuild | Bundled JS, still needs a runtime | Build speed and bundling, not native execution |
The honest positioning: scriptc is for TypeScript teams that want distribution — a 50KB binary you can scp to a server, put in a distroless container, or ship to users who don't have Node — without leaving TypeScript. It's not a reason to skip Rust for a database engine, and it's not production-hardened yet.
The takeaway
scriptc turns TypeScript into inspectable, linkable, genuinely native artifacts through a pipeline you can see at every stage — typed IR, readable C, LLVM IR, assembly, object, executable, WASM. The coverage-first workflow (scriptc coverage before every build) is the habit that makes it trustworthy: you always know which statements are native and which are dynamic islands. For a two-month-old Vercel Labs experiment sitting at #6 on GitHub trending, it's remarkably usable — install it, compile hello world, benchmark fib(32), and you'll understand both its promise and its current boundaries in half an hour.