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- Frontend — the real TypeScript compiler parses and type-checks your program against
es2025(plus@types/nodewhen your project has it), honoring yourtsconfig.jsonfor 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. - Typed IR — the only interface between the ends: a validated, serializable representation (
--emit=irwrites it as JSON and stops). Types are concrete here; generics have been monomorphized, unions are tagged values, closures have explicit captures. - Backends —
--emit=cwrites readable C and stops;--emit=llvmwrites 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|objsends 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 llvmpins it and fails with a diagnostic instead). The productionwasm32-wasitarget never falls back. - 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.oThe 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.
- Concurrency —
async/awaitruns 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 stack —
net,http,https,tls(vendored mbedTLS),dgram,dnsare 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
| Package | What it is |
|---|---|
packages/compiler | The 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-codegen | The LLVM 22 sidecar that verifies, optimizes, and emits target assembly or objects. |
packages/runtime | The 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-arm64 | The release-built runtime object pack and its target, ABI, feature, hash, system-library, compiler, and license manifest. |
packages/cli | scriptc build | run | coverage. |
The repository README covers the development workflow.