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
832040The 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:
- 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.
- Runs dynamically (with
--dynamic) — an embedded JavaScript engine (quickjs-ng, ~620KB) executes what can't be static: npm dependencies' shipped JS andany-typed code. Every value crossing back into static code is validated at runtime — a lying type throws a catchableTypeErrorinstead of corrupting memory. - 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 SC2013What 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/awaitwith JS-exact scheduling, exceptions withfinally, 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,
MapandSetwith JS-exact ordering,JSONwith runtime-validated casts,Math, typed arrays andBuffer,Errorhierarchies with typedcatch. - Node's API surface —
fs(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:
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
--dynamicembeds the engine for npm dependencies andany-typed code.scriptc coverage --dynamicreports exactly which statements run where. Static stays the default: a binary never silently grows an engine.- Checked casts —
JSON.parse(...) as Configinserts a runtime validation that throws a catchable error naming the offending path. TypeScript'sasis 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 stringcomptime(() => ...)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-22Correctness 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
- Quickstart — build the compiler and your first binary.
- CLI Reference — every command and flag.
- Coverage Reports — reading
scriptc coverageoutput. - npm Dependencies — the embedded-engine story.
- Limitations — what doesn't compile, and why.