scriptc: Compile TypeScript to Native Executables with Zero Runtime

Vercel Labs' scriptc compiles ordinary TypeScript into small, fast native binaries without Node or V8. Learn how it works, what compiles, and how it compares to Go and Rust.

scriptc: Compile TypeScript to Native Executables with Zero Runtime

Vercel Labs has open-sourced scriptc, a TypeScript-to-native compiler that transforms ordinary TypeScript into standalone native executables โ€” no Node.js, no V8, no JavaScript engine required in the final binary. The result? Binaries that start in ~2ms, occupy 170โ€“200KB, and consume 1โ€“4MB of memory.

The Problem: TypeScript's Runtime Tax

TypeScript developers love the language's type safety and tooling, but shipping a Node.js application means bundling a heavy runtime. Even with Node.js Single Executable Applications (SEA), binaries can balloon to 60โ€“100MB. Startup times hover around 47ms on modern hardware, and memory usage often exceeds 100MB RSS.

scriptc takes a different approach: instead of shipping a JavaScript engine, it compiles TypeScript directly to native code via LLVM or a C backend. The result is a binary that behaves byte-for-byte like Node.js but without the overhead.

How It Works

scriptc uses a three-tier compilation model:

  1. Compiled statically โ€” The default. TypeScript constructs that can be compiled to native code are compiled directly. No engine involved.
  2. Runs dynamically (--dynamic) โ€” An embedded QuickJS-ng engine (~620KB) executes code that can't be statically compiled, such as npm dependencies' shipped JavaScript or any-typed code. Values crossing back into static code are validated at runtime.
  3. Rejected โ€” Everything else fails with a specific error code, code frame, and usually a rewrite hint. Nothing is silently miscompiled.
$ scriptc coverage app.ts

statements analyzed 4481
compile statically 4451 (99%)

blockers:
ร—2 functions with optional parameters as values SC1090
ร—1 Promise.reject SC2020

What Compiles to Native

scriptc's static compilation surface covers the language features and standard library that real programs actually use:

Language Features

  • Classes with single inheritance and true dynamic dispatch (devirtualized when provably safe)
  • Closures with JavaScript capture semantics
  • Generics (monomorphized)
  • Discriminated unions as tagged values driven by TypeScript's own narrowing
  • async/await on stackful fibers with JavaScript-exact scheduling
  • Exceptions with finally
  • Destructuring, spread, optional/default/rest parameters
  • Getters/setters
  • Iterators over strings, arrays, Maps, Sets
  • Template literals
  • Regular expressions (using the same ECMAScript-exact bytecode interpreter as QuickJS)

Standard Library

  • Strings with UTF-16-exact semantics
  • Arrays, Maps, Sets with JavaScript-exact ordering and identity
  • JSON with runtime-validated casts
  • Math, typed arrays, Buffer
  • Error hierarchies with typed catch

Node.js API Surface

  • fs (sync and promises)
  • path (byte-exact port)
  • process, child_process with piped streams
  • os, crypto, url/URL, zlib
  • Timers and signal handlers on a dependency-free event loop
  • Server stack: net, http, https, tls (vendored mbedTLS), dgram, dns, fs.watch, readline

Web APIs

  • fetch and the WHATWG web subset (streams, Headers, AbortSignal) over the same native net/TLS stack
  • Redirects, gzip, AbortSignal.timeout, Node-shaped error causes
  • No libcurl, no system HTTP dependency

npm Dependencies (with --dynamic)

  • Packages resolve with Node's own algorithm
  • Type-check against their shipped .d.ts
  • JavaScript is embedded into the binary at build time
  • Binaries never read node_modules at runtime

Performance Comparison

Measured on Apple M-series against byte-identical workloads:

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 Integer inference and ownership analysis on the roadmap

Correctness Guarantees

scriptc runs two enforcement mechanisms on every change:

  1. Differential testing โ€” Every corpus program (800+ tests) runs under Node and as a native binary; stdout, stderr, and 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.

  2. Memory-safety lane โ€” The entire corpus re-runs under AddressSanitizer with a reference-count audit; leaks and use-after-free are build failures.

Escape Hatches

scriptc provides several escape hatches for when you need to break out of the static model:

  • comptime(() => ...) โ€” Runs TypeScript at build time (in an isolated VM inside the compiler) and bakes the result into the binary as a literal.
  • Native FFI (--ffi) โ€” Binds signature-only TypeScript declarations to direct C ABI calls and links manifest-declared archives, objects, and system libraries.
  • --dynamic โ€” Embeds the engine for npm deps and any code. 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.

Getting Started

# Install
npm install -g scriptc

# Run a TypeScript file directly
$ cat fib.ts
function fib(n: number): number {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
console.log(fib(30));

$ scriptc run fib.ts
832040

# Build a native binary
$ scriptc build fib.ts && ls -la fib
-rwxr-xr-x 178K fib  # a self-contained native binary, ~2ms startup

Requirements: clang (preinstalled with Xcode Command Line Tools). macOS arm64 is the primary platform; Linux and Windows binaries build by cross-compilation.

Architecture

The project is organized into three packages:

  • packages/compiler โ€” Frontend (tsc API โ†’ IR), the IR with validator/serializer, and the LLVM and C backends. The IR is the only interface between the ends; LLVM is the default code generator, and C is the reference backend.
  • packages/runtime โ€” The C runtime: refcounted values with a cycle collector, stackful fibers and the event loop (kqueue), the server stack, JS-exact number formatting. Feature units are link-gated: binaries pay only for what they use.
  • packages/cli โ€” scriptc build | run | coverage.

Why This Matters

scriptc represents a significant shift in how we think about TypeScript deployment. Instead of accepting the Node.js runtime tax, you can now compile your TypeScript to native binaries that start in milliseconds, use minimal memory, and are small enough to distribute as single files. For serverless functions, CLI tools, and edge computing, this could be transformative.

The project is still in early stages (v0.0.17 at the time of writing), but the foundation is solid: 800+ differential tests, memory safety verification, and a clear roadmap for future improvements like integer inference and ownership analysis.

If you're building TypeScript applications where startup time, memory usage, or binary size matter, scriptc is worth a serious look.

Source

vercel-labs/scriptc: TypeScript-to-Native Compiler