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):

DimensionScriptcContext
Startup~2.4msNode: ~47ms; on par with Zig, ahead of Go/Rust
Binary size170–200KB static, ~3MB with --dynamicGo: ~2MB; Node SEA: 60–100MB
Memory (RSS)1–4MB typicalNode: 67–116MB
RuntimeJS-faithful f64 semantics; competitive with systems languages on most workloadsInteger 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.
  • --dynamic embeds the engine for npm deps. scriptc coverage --dynamic reports exactly which statements run where.
  • Checked casts: JSON.parse(...) as Config inserts 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.