How It Works

The pipeline

TypeScript ──tsc: parse + typecheck──▶ lowering ──▶ typed IR ──▶ LLVM IR ──scriptc LLVM helper──▶ assembly/object
                                             │          │             └──precompiled runtime + linker──▶ executable
                                             │          └─────▶ C ────────────────C compiler/linker─────┘
                                             └── serialized IR
  1. Frontend — the real TypeScript compiler parses and type-checks your program against es2025 (plus @types/node when your project has it), honoring your tsconfig.json for checker strictness. The frontend then lowers the checked AST into a typed intermediate representation, using tsc's own type and narrowing answers to drive every decision. A construct with no lowering is a precise diagnostic at this stage — never a miscompile later.
  2. Typed IR — the only interface between the ends: a validated, serializable representation (--emit=ir writes it as JSON and stops). Types are concrete here; generics have been monomorphized, unions are tagged values, closures have explicit captures.
  3. Backends--emit=c writes readable C and stops; --emit=llvm writes textual LLVM IR and stops. Neither source-output command discovers or invokes a native toolchain. On supported macOS, Linux, and Windows hosts (and for WASI), --emit=asm|obj sends LLVM IR to a version-matched out-of-process LLVM 22 helper; it needs no clang or linker. Executable builds default to LLVM and can fall back to C on a native program outside the LLVM tier (one stderr note; --backend llvm pins it and fails with a diagnostic instead). The production wasm32-wasi target never falls back.
  4. Link — release packages contain one precompiled object per runtime feature unit plus QuickJS, libregexp, zlib, and mbedTLS archives for every supported target. A hashed manifest maps IR feature gates to an ordered typed link plan, so binaries still pay only for what they use. The user needs the target platform linker and SDK/sysroot, but ordinary LLVM-tier builds compile no C. AddressSanitizer, explicit C builds, and LLVM refusals keep the external C-toolchain path.

Program objects define main and leave their selected scr_* runtime functions undefined. The scr_runtime_abi_v1 reference is a strong link-time compatibility check. --print=native-link-info exposes the exact source runtime pack and link ordering for external builds; that object ABI is currently experimental and exact-runtime-version compatible, not semver-stable.

Inspect any stage yourself:

$ scriptc build fib.ts --emit=ir
$ ls .scriptc/
fib.ir.json
$ scriptc build fib.ts --emit=c
$ ls .scriptc/
fib.c
$ scriptc build fib.ts --emit=llvm
$ ls .scriptc/
fib.ll
$ scriptc build fib.ts --emit=asm
$ ls .scriptc/
fib.s
$ scriptc build fib.ts --emit=obj
$ ls .scriptc/
fib.o

The runtime

  • Memory — values are reference-counted; an acyclic value is freed the moment its last reference drops. Reference cycles are collected at deterministic points by a cycle collector, not a concurrent GC. There are no GC pauses and no tracing heap.
  • Concurrencyasync/await runs on stackful fibers with JS-exact scheduling: microtasks drain in the same order Node's do, timers fire in the same order, and the event loop (kqueue on macOS, epoll on Linux) has no external dependencies.
  • The server stacknet, http, https, tls (vendored mbedTLS), dgram, dns are native implementations on that same loop.
  • Numbers — JS-exact f64 semantics, including shortest-roundtrip number-to-string formatting fuzz-verified against Node's output.
  • Regular expressions — the same ECMAScript-exact bytecode interpreter QuickJS uses, linked only into regex-using binaries.

The correctness story: differential testing

scriptc's correctness claim is not "we implemented the spec" — it is "we ran your semantics against Node's and they matched." Two lanes enforce it on every change:

  • The differential corpus — every corpus program runs under Node and as a compiled binary; stdout, stderr, and exit codes must match byte-for-byte. Servers are exercised by live client drivers against both implementations. Number formatting was additionally fuzz-verified against Node's String(x) on a million random doubles.
  • The memory-safety lane — the entire corpus re-runs under AddressSanitizer with a reference-count audit at exit; a leak or use-after-free anywhere is a build failure. The same lane is available for your programs as scriptc build --sanitize.

Where matching Node byte-for-byte is impossible or deliberately not the goal (timing internals, error-object internals, aliasing at the dynamic boundary), the divergence is documented and numbered — see Limitations. Empty divergence space is load-bearing: the verified-identical list records what has been verified, not merely assumed.

The dynamic island

--dynamic embeds quickjs-ng for npm dependencies and any-typed code. Architecturally, the island is a second world with its own heap and its own microtask queue; the boundary between the worlds copies values and validates every dynamic → static crossing at runtime. Static code never trusts the island — a lying type is a catchable TypeError, not corruption.

Repository layout

PackageWhat it is
packages/compilerThe frontend (tsc API → IR), the typed IR with validator and serializer, the C and LLVM backends, native-helper integration, and the coverage analyzer.
native/llvm-codegenThe LLVM 22 sidecar that verifies, optimizes, and emits target assembly or objects.
packages/runtimeThe C runtime: refcounted values with a cycle collector, fibers and the event loop, the server stack, JS-exact number formatting, the island glue.
packages/runtime-darwin-arm64The release-built runtime object pack and its target, ABI, feature, hash, system-library, compiler, and license manifest.
packages/cliscriptc build | run | coverage.

The repository README covers the development workflow.