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.

Illustration of the scriptc compiler pipeline: typed code flowing through intermediate representation stages to a native binary
Credit: AI-generated illustration for AI Frontier Post

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 set SCRIPTC_LINKER to 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.

Illustration of a race between a heavy JavaScript runtime and a sleek native binary sprinter
Credit: AI-generated illustration for AI Frontier Post

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 --help banner 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 as SCRIPTC_LINKER fails on clang's -target flag. 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=zigcc with SCRIPTC_TARGET=wasm32-wasi, and APIs like network sockets or fetch fail before linking with diagnostic SC3002 — 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 coverage before scriptc build should 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 --dynamic warnings imply — but when inference gives up, the diagnostic tells you exactly where.

When to use this vs the alternatives

ToolWhat you getWhen it wins
scriptcTrue native binary (or WASI module) from TS, no runtimeShipping tiny CLIs/daemons from a TypeScript codebase; wanting to inspect the generated C
Bun single-file executableBinary embedding Bun's runtimeFull Node-API compatibility matters more than binary size
pkg / Node SEABinary embedding NodeExisting Node apps you want to distribute as one file, unchanged
Rust / GoNative binary from a systems languageMaximum control and ecosystem maturity; rewriting in TS isn't an option
esbuildBundled JS, still needs a runtimeBuild 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.