---
title: How It Works
url: "https://scriptc.dev/docs/how-it-works"
docs_index: /llms.txt
lastUpdated: 2026-11-13
---

> For an index of all documentation, see [/llms.txt](/llms.txt).

This page describes how scriptc turns a TypeScript program into an executable: the compiler stages, the outputs you can inspect at each stage, the native runtime that executables link, and how the compiler is tested.

## Compiler pipeline

```text
TypeScript / JavaScript
         |
         v
TypeScript parser and type checker
         |
         v
Lowering and typed IR ----> serialized IR (--emit=ir)
         |
         v
LLVM IR -----------------> LLVM source (--emit=llvm)
         |
         v
LLVM helper -------------> assembly / object (--emit=asm|obj)
         |
         v
Runtime pack and linker -> executable
```

1. **Frontend:** TypeScript parses and checks the program against `es2025`, scriptc's ambient declarations, and available project declarations. `tsconfig.json` controls checker strictness and, through `verbatimModuleSyntax`, whether [imports used only as types are dropped](/docs/dependencies#package-resolution). [Type checking](/docs/limitations#type-checking) lists the settings scriptc takes from it. The compiler uses the checked types and narrowing information to lower supported operations; unsupported operations produce diagnostics. JavaScript files that TypeScript checks (a `// @ts-check` comment, or `checkJs` in `tsconfig.json`) report values that do not match their JSDoc types as errors, and their classes use those types the way TypeScript classes use annotations.
2. **Typed IR:** A validated, serializable intermediate representation connects the frontend and backend. Types are concrete: generic functions are specialized, unions use tags, and closures have explicit captures. `--emit=ir` writes this representation as JSON.
3. **LLVM backend:** `--emit=llvm` writes textual LLVM IR. The matching LLVM 22 helper generates assembly or objects on supported macOS, Linux, and Windows hosts, including output targeting WASI. Executable and library builds use this backend. Large executables, native and WASI alike, are divided into partitions that are optimized and compiled in parallel; see [Build parallelism](#build-parallelism).
4. **Linking:** Runtime packs contain the precompiled C runtime and vendored libraries. A hashed manifest selects the artifacts the program needs for its features, optimization mode, and (for libraries) state model. The target linker and SDK or sysroot produce the executable. Sanitizer builds use an instrumented C runtime.

`comptime` calls run during the frontend stage in a separate evaluator process based on QuickJS. A callback sees the ECMAScript built-ins and no object of the compiler or the host, and each call has a time limit and a memory limit. Their results are embedded in the program as constants. See [build-time evaluation](/docs/limitations#build-time-evaluation) for the rules and limits.

## Build parallelism

Most of a build runs on one CPU core. The LLVM helper uses more cores when it compiles an executable in partitions, and when it generates code for an optimized executable that it optimizes whole.

### Frontend

Parsing, type checking, lowering, and LLVM IR generation run one after another on one thread. The type checker runs in a separate process that the compiler waits on. When none of a build's inputs has changed since an earlier build, the frontend's results come from the build cache and this stage is skipped. A change to any input runs the whole stage again.

### Partitions

The LLVM helper divides an executable's LLVM IR into partitions by its size and the threads the host offers. Up to 128 KiB of LLVM text is one partition. A larger program gets one partition per 4 MiB of LLVM text and two per hardware thread of the host, whichever is more, though a host's threads add no more partitions than the program can fill at 32 KiB of text each. The count is rounded up to a power of two, and is at least 8 and at most 64. `--emit=llvm` writes this text, so its file size and the host's thread count show how many partitions a program gets. `dev` executables, whose partitions are compiled on their own with nothing imported, are divided from 64 KiB: up to 128 KiB is four partitions. An edit compiles again only the partitions whose code it changes, or that inline a function it changes.

<table>
  <thead>
    <tr>
      <th>Hardware threads</th>

      <th>Partitions of a program of 32 MiB or more</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>Up to 4</td>

      <td>8</td>
    </tr>

    <tr>
      <td>5 to 8</td>

      <td>16</td>
    </tr>

    <tr>
      <td>9 to 16</td>

      <td>32</td>
    </tr>

    <tr>
      <td>17 or more</td>

      <td>64</td>
    </tr>
  </tbody>
</table>

A smaller program gets the count its own text fills: on a host with 16 hardware threads, a program of just over 128 KiB gets 8 partitions, one of 512 KiB 16, and one of 1 MiB 32. Two hosts with the same number of hardware threads give a program the same partitions, and build the same executable; a host with more threads divides the program more finely unless its size already asks for more partitions than the threads do. In programs with 32 partitions or more, functions from the same source file are placed in the same partition, so that their calls to each other stay inside it; a file larger than 1 MiB of LLVM text or than an average partition is divided into parts by function name. Other functions, and the functions of smaller programs, are placed by their name, and a small internal function goes with the one function that calls it. An edit leaves almost every other function in its partition.

An optimized partition is optimized twice: on its own, and again after the small functions of other partitions it calls are imported for inlining. So in optimized builds, a program whose largest partition would hold more than two thirds of its function text is optimized whole instead, as one partition, and its code is generated in parts on several threads.

These builds are not divided into partitions and compile on one thread: `--emit=asm`, `--emit=obj`, and `--print=native-link-info` output, library builds (`--lib`; `dev` library builds are compiled in pieces, one after another), and `--sanitize` builds, which compile the LLVM IR with the C compiler.

The helper compiles partitions on a pool with one thread per hardware thread of the host, so an executable with four partitions uses at most four cores in the backend.

How partitions are compiled depends on `--optimization`:

- `dev` compiles each partition on its own, all in parallel.
- `release` and `speed` optimize each partition and write a summary of its functions, all in parallel. Then a serial step reads every summary and decides which small functions each partition imports from the others, so that calls between partitions can still be inlined. Last, the partitions are optimized with their imports and compiled, again in parallel.

Reading the LLVM text and dividing it into partitions, and the import step, run on one thread.

### Code generation of a program optimized whole

A `release` or `speed` executable whose largest partition would hold more than two thirds of its function text is optimized as one module on one thread. The helper then divides the optimized module for code generation into as many parts as the program has partitions and generates machine code for the parts in parallel, on the same pool. Each part holds a run of consecutive functions with about the same amount of code, so the executable keeps the functions in the same order. The module is optimized before it is divided, so each function's machine code is the same as from one thread, except that a jump to a function that another part also uses takes the longer form. When one function holds most of the code, the first part holds the whole module and the others are empty.

An executable of one partition (up to 128 KiB of LLVM text, or 64 KiB in `dev` builds) is compiled on one thread.

### Rebuilds

With two or more partitions, the helper keeps each partition's summary and object in the build cache (see `SCRIPTC_CACHE_DIR` in the [CLI reference](/docs/cli#environment-variables)). A rebuild after an edit compiles again only the partitions whose functions changed and, in `release` and `speed` builds, the partitions that import a changed function; the others come from the cache. Dividing the program, the import step, and linking run on every build. A cold build, with nothing cached, compiles every partition. A program optimized whole is compiled again whole after any edit.

A program of one partition has no partition cache: a rebuild after an edit compiles the whole program again, and a rebuild that leaves the LLVM text unchanged takes the compiled code from the build cache.

The partition count is part of a cached partition's key, and a cached executable records the count it was linked from, so a build cache shared between hosts that divide a program differently serves each host its own builds.

When the LLVM text grows past a partition boundary (128 KiB, 512 KiB, 1 MiB, 2 MiB, and on in 4 MiB steps for large programs), the partition count doubles, functions move to new partitions, and the next build compiles every partition again.

### Profile-guided optimization

Building with a profile adds these costs (see [Profile-guided optimization](/docs/cli#profile-guided-optimization)):

- `--profile-generate` builds an instrumented executable, which runs slower while it records the profile.
- The profile's contents are part of every partition's cache key. A new or changed profile compiles every partition again; builds without a profile keep their own cache entries.
- With a profile, the import step lets a hot call import a function of up to twice the usual size limit. LLVM's default limits for profiled calls are 10 and 100 times as large, and on large programs they tripled the build's time and memory without making the program measurably faster.
- The profile does not change which partition a function is in.

Collecting a profile takes an instrumented build, the training runs, and the merge. With a committed profile, later builds skip those steps and are one optimized build each.

## Inspect compiler output

The following examples use this program:

```ts title="fib.ts"
function fib(n: number): number {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
console.log(fib(30));
```

Select an output stage with `--emit`:

```console
$ scriptc build fib.ts --emit=ir >/dev/null
$ ls .scriptc/
fib.ir.json
$ scriptc build fib.ts --emit=llvm >/dev/null
$ ls .scriptc/
fib.ir.json
fib.ll
$ scriptc build fib.ts --emit=asm >/dev/null
$ ls .scriptc/
fib.ir.json
fib.ll
fib.s
$ scriptc build fib.ts --emit=obj >/dev/null
$ ls .scriptc/
fib.ir.json
fib.ll
fib.o
fib.s
```

Without `-o`, artifacts are written to `.scriptc/` next to the input. Different output kinds accumulate in that directory; rebuilding a kind updates its file.

### Lowered programs

Work on the phases behind the frontend (IR validation, LLVM emission, native code generation, the link) does not need the frontend to run again for each attempt. `--emit-lowered` makes a build also write its lowered program to a file, and `--from-lowered` starts a build from that file:

```console
$ scriptc build fib.ts -o fib --emit-lowered fib.lowered >/dev/null
$ scriptc build fib.ts -o fib --from-lowered fib.lowered >/dev/null
$ ./fib
832040
```

The second build runs no type checker and no lowering, and writes the same executable byte for byte. The file holds the validated IR, the source texts that code frames and debug information read, the warnings the build reported, and the program's Node islands. The build caches cannot do this, because the compiler is one of their keys and a changed compiler is the reason to build again.

The file also records what the program was lowered from, and a build from it checks each kind and names the one that differs:

- **compiler:** the build of the compiler that lowered the program.
- **options:** the entry file, the target platform, the FFI manifest, every option except the output paths, and the `SCRIPTC_*` environment variables the frontend reads.
- **inputs:** every file, directory listing, and path the frontend read, including `tsconfig.json`, `package.json`, and the declaration files of dependencies.

```console
$ echo "// a comment" >> fib.ts
$ scriptc build fib.ts -o fib --from-lowered fib.lowered
/tmp/demo/fib.ts:1:1 - error SC3002: --from-lowered: /tmp/demo/fib.lowered does not match this build's inputs: /tmp/demo/fib.ts changed since it was lowered

  hint: lower the program again with --emit-lowered, or pass --from-lowered-accept=inputs to build from the file anyway

1 error.
```

`--from-lowered-accept` builds from the file anyway and reports the difference on stderr. After a change to the compiler's backend, pass `--from-lowered-accept=compiler`: the sources and options are still checked, and the build uses the program as the earlier compiler lowered it, so a change to the frontend has no effect until the program is lowered again.

```console
$ scriptc build fib.ts -o fib --from-lowered fib.lowered --from-lowered-accept=inputs
scriptc: warning: --from-lowered: building from /tmp/demo/fib.lowered although its inputs differ: /tmp/demo/fib.ts changed since it was lowered
/tmp/demo/fib
```

A build from a file validates the IR again before it emits. An IR artifact written by `--emit=ir` is also accepted as the file; it records none of the three kinds, so the build needs all of them accepted.

### External linking

Program objects define `main` and leave selected `scr_*` runtime functions undefined. A strong reference to `scr_runtime_abi_v21` checks runtime compatibility at link time.

`--print=native-link-info` emits the program object and a JSON recipe containing the runtime pack and link order for external builds. This object ABI is experimental and requires an exact runtime version. See [Native Program Objects](/docs/native-objects) for examples.

## Native runtime

- **Memory:** Values use reference counting. Acyclic values are freed when their last reference is released, and a cycle collector runs at defined collection points. When the compiler proves that a group of objects is released together, such as a tree that a recursive function builds and its caller only reads, the group is allocated in an arena and released at once, without freeing each object or collecting its cycles. Some cycles involving opaque native values can remain allocated. When the main thread's script ends, the process exits without freeing what the program still holds, and the operating system reclaims it; worker threads free their heaps when they exit. Builds with `--sanitize`, and processes run with `SCRIPTC_EXIT_FREE=1` (for external leak checkers), free everything at exit.
- **Concurrency:** An `async` function or async generator that suspends only at its own `await` and `yield` expressions compiles to a stackless coroutine whose heap frame holds the values live across them; other suspending `async` functions and async generators use stackful fibers, as do debug builds, libraries, worker programs and Windows executables. The native event loop schedules microtasks, timers, and I/O, using kqueue on macOS and epoll on Linux.
- **Networking:** Supported `net`, `http`, `https`, `tls`, `dgram`, and `dns` APIs use native implementations. TLS uses vendored mbedTLS.
- **Node islands:** A package that runs in a Node island is loaded by Node.js inside the program's process, the first time the program uses it. The package's JavaScript travels in the executable and is extracted once into the user's cache directory, `$XDG_CACHE_HOME/scriptc/node-islands/<digest>/` or `~/.cache/scriptc/node-islands/<digest>/`, where the digest names the set of files. Beside it, in `<digest>.compile-cache/`, the program keeps Node.js's [module compile cache](https://nodejs.org/docs/latest-v24.x/api/module.html#module-compile-cache) for those files, so a later start loads the compiled code instead of compiling the source again. A program that runs the `typescript` package in an island started in 205 ms without the cache and in 102 ms with it; its first start, which installs the files and fills the cache, took 244 ms instead of 218 ms. A file that changed is compiled again, and a rebuilt program has directories of its own. `NODE_DISABLE_COMPILE_CACHE=1` runs a program without the cache, and `NODE_COMPILE_CACHE=<directory>` makes Node.js use a directory of your choice instead. A program that never starts its island creates neither directory.
- **Numbers:** Values use JavaScript's double-precision representation. Number-to-string formatting uses shortest-roundtrip output and is tested against Node.js.
- **Regular expressions and Unicode:** The runtime uses QuickJS's regular-expression and Unicode libraries, linked into programs that use them.

See [Limitations](/docs/limitations) for runtime differences and the [Node.js compatibility reference](/compatibility) for API support.

## Testing

The test suite runs each test program under Node.js and as a compiled executable, and compares stdout, stderr, and exit codes. Server tests drive both implementations with the same clients. Number formatting is also fuzz-tested against Node.js's `String(x)`.

The same tests also run with AddressSanitizer and a reference-count audit at exit, to catch leaks and memory errors. You can enable these checks in your own executable builds with `--sanitize`.

Test coverage provides evidence for the cases exercised. It does not establish complete compatibility with Node.js.

<span id="embedded-javascript-engine" />

## Repository layout

<table>
  <thead>
    <tr>
      <th>
        Path
      </th>

      <th>
        Purpose
      </th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>
        <code>packages/compiler</code>
      </td>

      <td>
        Frontend, typed IR, validation, serialization, LLVM backend, native-helper integration, and coverage analysis.
      </td>
    </tr>

    <tr>
      <td>
        <code>native/llvm-codegen</code>
      </td>

      <td>
        LLVM 22 helper for verification, optimization, assembly, and object output.
      </td>
    </tr>

    <tr>
      <td>
        <code>packages/runtime</code>
      </td>

      <td>
        C runtime, reference counting and cycle collection, fibers, event loop, networking, and the Node.js API implementations.
      </td>
    </tr>

    <tr>
      <td>
        <code>packages/runtime-\*</code>
      </td>

      <td>
        Runtime object packs and manifests for individual targets.
      </td>
    </tr>

    <tr>
      <td>
        <code>packages/cli</code>
      </td>

      <td>
        CLI commands, including

        <code>build</code>

        ,

        <code>run</code>

        , and

        <code>coverage</code>

        .
      </td>
    </tr>
  </tbody>
</table>

See the [repository README](https://github.com/vercel-labs/scriptc) for the development workflow.

---

For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md)

For an index of all available documentation, see [/llms.txt](/llms.txt)

For agent-facing discovery, including API and MCP surfaces, see [/agents.md](/agents.md)