comptime, for JavaScript
A Zig-inspired primitive that runs a function during your build and inlines the result. One import, two plugins, and no new syntax.
Zig has comptime: mark an expression, and the compiler evaluates it before your program ever runs. JavaScript does not have this. We have build tools that could do it, and macros that almost do, but nothing that feels like calling a function.
So I wrote one.
import { comptime } from 'comptime';
import { fibonacci } from './math';
export const value = comptime(() => fibonacci(10));
With the plugin on, the build evaluates that arrow function and replaces the call with the serialized result:
export const value = 55;
That is the whole API. The rest of this post is about the edges.
#Fail loudly
The runtime export is real. If the plugin is not enabled, it throws:
export function comptime<T>(fn: () => T | Promise<T>): T {
throw new Error('comptime() was called at runtime. Is the plugin enabled?');
}
I went back and forth on this. A silent fallback, where the function just runs at runtime, is friendlier for the first five minutes. It is also how you ship a 400 kB lookup table to production without noticing. A missed transform should be a crash, not a slow page.
#Setup
Both plugins share one core. Pick the one that matches your build:
// vite.config.ts
import { defineConfig } from 'vite';
import { comptime } from 'comptime/vite';
export default defineConfig({
plugins: [comptime()],
});
// rolldown.config.ts
import { defineConfig } from 'rolldown';
import { comptime } from 'comptime/rolldown';
export default defineConfig({
input: 'src/app.ts',
plugins: [comptime()],
});
In Vite dev, evaluation goes through server.ssrLoadModule, so it shares Vite's module graph and cache. In Vite build and in Rolldown, an internal Rolldown evaluator does the work.
#What gets captured
The hard part is not running the function. It is running it somewhere else, with everything it closes over. The plugin builds a virtual module for each call site:
- Imported
comptimebindings are found, including aliases. A local variable that shadowscomptimeis ignored. - Every value import the function references is rewritten to an absolute path and copied in.
- Every top-level declaration from the same file that the function references is copied in.
- The virtual module is evaluated. If the body returns a promise, it is awaited.
- The result is serialized with
devalueand spliced back in place of the call.
So this works, even though TABLE_SIZE and crc never leave the build:
import { comptime } from 'comptime';
import { crc } from './crc';
const TABLE_SIZE = 256;
export const CRC_TABLE = comptime(() => {
let table = new Uint32Array(TABLE_SIZE);
for (let i = 0; i < TABLE_SIZE; i++) table[i] = crc(i);
return table;
});
devalue handles typed arrays, Map, Set, Date, BigInt, and repeated references. Plain JSON.stringify would turn that table into an object with 256 string keys.
#Async is free
Because promise bodies are awaited, build-time fetches just work:
export const release = comptime(async () => {
let res = await fetch('https://api.github.com/repos/lukeed/comptime/releases/latest');
let { tag_name, published_at } = await res.json();
return { tag: tag_name, date: new Date(published_at) };
});
The Date survives the trip because of devalue, not because of anything clever on my side.
#Errors point at your code
When a comptime body throws, the build error includes the original call site, not a line in a virtual module you have never seen:
[comptime] src/version.ts:4:24
ReferenceError: process is not defined
4 | export const sha = comptime(() => process.env.GIT_SHA);
| ^
This took longer than the evaluator.
#Rules
The plugin only accepts one zero-argument arrow function or function expression. No arguments, no identifiers, no comptime(myFn). That keeps every call site readable in isolation: everything that runs at build time is inside the parentheses.
There are more examples in the repo, including a Markdown precompiler, a preact-render-to-string snapshot, and environment stamping. Install it with bun add --dev comptime and see the source on GitHub.