Scriptc: TypeScript to Native Without the Engine
Vercel Labs just released Scriptc, a compiler that transforms ordinary TypeScript into native executables. No Node, no V8, no JavaScript engine in the binary. The result: a 178KB self-contained binary that starts in ~2ms and runs the same code identically to Node.
$ cat fib.ts
function fib(n: number): number {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
console.log(fib(30));
$ scriptc build fib.ts && ls -la fib
-rwxr-xr-x 178K fib
$ ./fib
832040
No code changes. No annotations. The same TypeScript you run on Node, type-checked by the real TypeScript compiler, then compiled to native. What compiles behaves byte-for-byte like Node.
How It Works
Scriptc analyzes your TypeScript construct by construct, deciding what can compile statically. It reports a coverage summary:
$ scriptc coverage app.ts
statements analyzed 4481
compile statically 4451 (99%)
blockers:
×2 functions with optional parameters as values SC1090
×1 Promise.reject SC2020
Three tiers:
- Compiled statically — native code, no engine. Default.
- Runs dynamically (
--dynamic) — an embedded QuickJS engine (~620KB) executes what can't be static. Every value crossing back into static code is validated at runtime. - Rejected — fails with a specific error code and a rewrite hint. Nothing is silently miscompiled.
What Compiles
The static surface covers the language features real programs use: classes with single inheritance and virtual dispatch (devirtualized when safe), closures, generics (monomorphized), discriminated unions, async/await on stackful fibers, exceptions, destructuring, spread, template literals, and regular expressions (using QuickJS's ECMAScript-exact bytecode interpreter, linked only into regex-using binaries).
The standard library includes strings with UTF-16-exact semantics, arrays/Maps/Sets with JS ordering, JSON with runtime-validated casts, Math, typed arrays, Buffer, and Error hierarchies.
Node's API surface is largely covered: fs (sync and promises), path, process, child_process, os, crypto, url, zlib, timers, signal handlers, net, http, https, tls (vendored mbedTLS), dgram, dns, fs.watch, readline. Real proxy servers compile.
fetch and the WHATWG web subset (streams, Headers, AbortSignal) run over the same native net/TLS stack — no libcurl, no system HTTP dependency.
npm dependencies (with --dynamic) resolve with Node's algorithm, typecheck against shipped .d.ts, and their JS is embedded into the binary at build time. Binaries never read node_modules at runtime.
Correctness Guarantees
Two enforcement mechanisms run on every change:
- Differential testing — every corpus program (800+ tests) runs under Node and as a native binary; stdout, stderr, exit codes must match byte-for-byte. Number formatting is JS-exact (shortest-roundtrip, fuzz-verified against Node on a million doubles). Servers are tested with live client drivers against both implementations.
- Memory-safety lane — entire corpus re-runs under AddressSanitizer with a reference-count audit; leaks and use-after-free are build failures.
Deliberate divergences from Node (a few dozen, mostly around timing internals and error-object properties) are documented and numbered; nothing diverges silently.
Performance
Measured on Apple M-series against Node, Go, Rust, and Zig (all byte-identical output, verified):
| Dimension | Scriptc | Context |
|---|---|---|
| Startup | ~2.4ms | Node: ~47ms; on par with Zig, ahead of Go/Rust |
| Binary size | 170–200KB static, ~3MB with --dynamic | Go: ~2MB; Node SEA: 60–100MB |
| Memory (RSS) | 1–4MB typical | Node: 67–116MB |
| Runtime | JS-faithful f64 semantics; competitive with systems languages on most workloads | Integer inference and ownership analysis on the roadmap |
Escape Hatches
comptime(() => ...)runs TypeScript at build time and bakes the result into the binary.- Native FFI (
--ffi) binds signature-only TypeScript declarations to direct C ABI calls. --dynamicembeds the engine for npm deps.scriptc coverage --dynamicreports exactly which statements run where.- Checked casts:
JSON.parse(...) as Configinserts a runtime validation that throws a catchable error naming the offending path.
Architecture
TS → tsc (parse + typecheck) → lowering → typed IR → C → clang → native executable
The IR is the only interface between the ends. LLVM is the default code generator, with C as the reference backend forever.
Development
$ pnpm install && pnpm build
$ pnpm test # differential corpus + diagnostics snapshots
$ SCRIPTC_SAN=1 pnpm test # the same corpus under ASan + RC audit
$ pnpm scriptc build x.ts --emit-ir # keep .scriptc/x.c and x.ir.json
Every feature lands with differential tests; both lanes green is the merge bar.
Getting Started
Requires clang (preinstalled with Xcode Command Line Tools). macOS arm64 is the primary platform; Linux and Windows binaries build by cross-compilation, each verified by its own differential test lane.
# Install (from GitHub)
# Follow the README at https://github.com/vercel-labs/scriptc
Scriptc is in early development but already compiles real-world proxy servers. If you have TypeScript code that's mostly static, try scriptc coverage and see what compiles.


