Introduction

scriptc compiles ordinary TypeScript into small, fast native executables — no Node, no V8, no JavaScript engine in the binary. No changes to your code: no annotations, no dialect, no special standard library. The same TypeScript you run on Node, type-checked by the real TypeScript compiler, compiled to native code. What compiles behaves byte-for-byte like Node.

$ 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

$ scriptc build fib.ts -o fib && ./fib
832040

The result is a self-contained executable in the ~320KB size class that starts in a few milliseconds and links against nothing but the system C library:

$ otool -L fib
fib:
        /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)

The idea: staticness you can see

Most TypeScript is far more static than the ecosystem assumes. scriptc decides, construct by construct, what can compile to native code — and tells you. Every construct in your program lands in exactly one of three tiers, and the tier is the promise:

  1. Compiled statically — native code, no engine. The default, and the only mode unless you opt out. A program that stays in this tier produces byte-identical stdout and the same exit code as running the same file under Node, apart from a short, numbered list of documented divergences.
  2. Runs dynamically (with --dynamic) — an embedded JavaScript engine (quickjs-ng, ~620KB) executes what can't be static: npm dependencies' shipped JS and any-typed code. Every value crossing back into static code is validated at runtime — a lying type throws a catchable TypeError instead of corrupting memory.
  3. Rejected — everything else fails at compile time with a specific error code, a code frame, and usually a rewrite hint. Nothing is ever silently miscompiled.

The coverage report makes the tiers visible for your program:

$ scriptc coverage cli.ts

  statements analyzed   4
  compile statically    3  (75%)

  runs with --dynamic   2 sites (embeds a JS engine, ~620KB — static stays the default)
      ×1  importing 'picocolors' requires the embedded dynamic engine, which this build does not include — the package's implementation runs there  SC2013
      ×1  values from the 'picocolors' package run in the embedded dynamic engine, which this build does not include                                SC2013

What compiles statically

The static surface covers the language and the standard library real programs use:

  • The language — classes with single inheritance and dynamic dispatch, closures with JS capture semantics, generic function declarations (monomorphized), discriminated unions driven by TypeScript's own narrowing, async/await with JS-exact scheduling, exceptions with finally, destructuring, spread, optional/default/rest parameters, getters and setters, iterators, template literals, bitwise operators with JS-exact ToInt32 semantics, and the static slice of regular expressions.
  • The standard library — strings with UTF-16-exact surface semantics, arrays, Map and Set with JS-exact ordering, JSON with runtime-validated casts, Math, typed arrays and Buffer, Error hierarchies with typed catch.
  • Node's API surfacefs (sync and promises), path, process, child_process, os, crypto, url/URL, zlib, timers and signal handlers on a dependency-free event loop, and the server stack: net, http, https, tls, dgram, dns, readline. Real servers compile:
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, pid: process.pid }));
});

server.listen(8080, () => {
  console.log("listening on http://localhost:8080");
});
$ scriptc build server.ts -o server && ./server &
listening on http://localhost:8080
$ curl -s http://localhost:8080/status
{"path":"/status","pid":90126}

Programs typecheck against TypeScript's real es2025 lib (plus @types/node when your project has it), and your tsconfig.json governs checker strictness. Anything reached that has no lowering is a precise diagnostic, never a surprise. The limitations page lists what doesn't compile and what diverges by design, in plain language.

Escape hatches and their costs

  • --dynamic embeds the engine for npm dependencies and any-typed code. scriptc coverage --dynamic reports exactly which statements run where. Static stays the default: a binary never silently grows an engine.
  • Checked castsJSON.parse(...) as Config inserts a runtime validation that throws a catchable error naming the offending path. TypeScript's as is a promise; scriptc verifies it:
$ cat cast.ts
type Config = { port: number };
try {
  const cfg = JSON.parse('{"port": "eighty"}') as Config;
  console.log(cfg.port);
} catch (e) {
  if (e instanceof Error) console.log(`caught: ${e.message}`);
}

$ scriptc run cast.ts
caught: expected number at $.port, got string
  • comptime(() => ...) runs TypeScript at build time (in an isolated VM inside the compiler) and bakes the result into the binary as a literal:
$ cat banner.ts
const build = comptime(() => `built ${new Date().toISOString().slice(0, 10)}`);
console.log(build);

$ scriptc run banner.ts
built 2026-07-22

Correctness as methodology

Two enforcement mechanisms run on every change:

  • Differential testing — every corpus program 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). Servers are tested with live client drivers against both implementations.
  • Memory-safety lane — the entire corpus re-runs under AddressSanitizer with a reference-count audit; leaks and use-after-free are build failures.

The deliberate divergences from Node — mostly around timing internals and error-object properties — are documented and numbered; nothing diverges silently. How It Works covers the architecture behind this.

Where to go next