How the Compiler Works
Every framework with dependency injection has to answer the same question at some point: "given this class's constructor, where do the arguments come from?" Most answer it at runtime — a container walks your classes on every boot, matches constructor parameters to registered providers, and builds instances in the right order. Heximon answers it once, ahead of time, as part of your build. This page is about that decision and what it buys you.
Why a build step
The wiring problem — "which concrete class satisfies this constructor parameter" — doesn't change between requests, and for most apps it doesn't change between deploys either. It's determined entirely by your source: which classes exist, what they ask for, which module lists them. That's exactly the kind of problem a build step is good at solving once instead of paying for on every cold start.
You already trust a build step for this shape of work — a bundler resolves your imports and produces
a dependency graph before your app runs a single line. Heximon's compiler is the same kind of tool, just
reading one more layer: not only "what does this file import" but "what does this class's constructor
need, and which other class in the graph provides it." It runs as part of pnpm dev and pnpm build
via the heximon() Vite plugin, the same place your bundler already runs.
The payoff: no runtime container. app.get(UsersRepository) doesn't walk anything at request time — the
compiler already decided that UsersRepository is built with new UsersRepository() and wrote that call
into a generated file. A missing dependency isn't a 5xx in production; it's a build that never shipped.
What the compiler reads
The compiler starts from your module configs, not a whole-codebase scan — if a class isn't listed in a
Module({...}), it doesn't exist to the compiler. From there it reads three things per class, using
oxc (a Rust-based parser, not tsc) to parse your source without type-checking it:
- Module configs —
providers,imports,exports, and each plugin's namespace key (http: { controllers },events: { handlers }, …) — tell it which classes exist and how modules compose. - Constructor signatures — a class's constructor parameter types are its dependency declaration.
No injection annotations, no reflection:
constructor(private readonly users: UsersRepository)tells the compiler exactly what to pass. - Heritage clauses — the
extends/implementsclause a listed class declares (implements Controller<"/users">,implements EventHandler<OrderPlaced>) tells the compiler which kind of concept it's looking at, and its type argument carries the binding (the route prefix, the event class, …).
That's the entire input. No decorators to read, no metadata to reflect on, no runtime scan.
What it emits
Take a real module — a controller with one dependency. Here's what you write, and what the compiler emits from it:
import { Module } from "@heximon/runtime";
import { UsersController } from "./users.controller";
import { UsersRepository } from "./users.repository";
export class UsersModule extends Module({
providers: [UsersRepository],
http: { controllers: [UsersController] },
exports: [UsersRepository],
}) {}
import type { Controller, Get } from "@heximon/http";
import { type User, UsersRepository } from "./users.repository";
export class UsersController implements Controller<"/users"> {
constructor(private readonly users: UsersRepository) {}
public async list(_action: Get<"/", { scopes: ["public"] }>): Promise<User[]> {
return this.users.list();
}
public async get(action: Get<"/:id">): Promise<User | { error: string }> {
const user = await this.users.findById(action.request.pathParams.id);
return user ?? { error: "User not found" };
}
}
// GENERATED by Heximon — do not edit.
export async function initUsersModule(inputs, disposables) {
const UsersRepository_ = new UsersRepository();
const UsersController_ = new UsersController(UsersRepository_);
return {
UsersRepository: UsersRepository_,
UsersController: UsersController_,
};
}
That's it — plain, readable JS. UsersRepository is constructed first because UsersController needs
it; the resolved instance is passed straight into UsersController's constructor as a positional
argument. No container, no token lookup, no reflection at runtime — just a function call the compiler
already knows is correct because it checked your constructor signature against the module's provider
list at build time. Every module gets one of these init functions, and a top-level createApp() (also
generated) composes them into the app your fetch handler serves.
The snippet above is trimmed for the page — the real generated output additionally wires the route
table, the request-logging middleware inherited from the root module, and the app's get/shutdown
surface. Read the full thing yourself: by default pnpm dev (and pnpm build) hold this wiring in memory
and never touch disk, so opt into writing it out first:
import { defineHeximonConfig } from "@heximon/build";
export default defineHeximonConfig({
debug: true,
});
Then run pnpm dev in examples/ladder/L01-minimal and open .heximon/ at the project root.
What comes back to your editor
A build step that only reports errors on pnpm dev's terminal would still be slow feedback. Heximon
closes that loop from the other direction too: every compile writes src/.heximon/graph/verdicts.gen.d.ts,
a generated declaration file that pins each compile verdict back onto the exact class or config entry it's
about — a dirty module's declaration picks up an error keyed to the mistake, so a broken provider list or
an unmet requires shows up as a TypeScript error on the right line, without you running anything.
This isn't a replacement for the build — it's a mirror of it. Some DI mistakes (an unresolvable ambiguity
between two providers, a cyclic import) can only be caught by actually running the graph resolution, so
they still surface at pnpm dev/pnpm build time first and flow back into the editor on the next
compile. What you get is real but bounded: DI mistakes the compiler already understands show up close to
where you typed them, not exhaustively before you ever build.
The editor sees the file through a single tsconfig line, because TypeScript's default src glob skips
dot-directories:
{
"include": ["src", "src/.heximon/**/*"]
}
There's no setup cost: pnpm create heximon scaffolds it, and the first vp dev / vp build
self-heals it into an existing project's tsconfig.json (comments and formatting preserved — the same
convention the library build uses for package.json exports). The heal only touches a file that declares
its own include; if yours is inherited from a shared base config, add the entry there by hand.
Escape hatches
.heximon/ is gitignored and fully disposable — and, by default, it isn't even on disk: pnpm dev/pnpm build
hold the generated wiring in memory unless heximon.config.ts sets debug: true (above), while a
non-Vite bundler or a Nitro-hosted app writes it out unconditionally. Written or not, it's readable JS, not
a bundle — open it, step through it in a debugger, and confirm for yourself that it does what the doc
above claims. Delete the whole directory and the next compile regenerates it from scratch. Nothing about your
application code depends on its contents; nothing you write ever imports from .heximon/ directly. If
the compiler is ever wrong about something, you're never locked out of understanding — or fixing — what
it produced.
See also
- Compiler Diagnostics — how a build error reads when the compiler can't resolve your graph.
- Extending the Compiler — write a compiler plugin to add a new discoverable concept.
- Modules — the
Module({...})config this page's graph is built from.
Deploying a Heximon app
heximon deploy runs vp build then ships via wrangler deploy, vercel deploy --prebuilt, netlify deploy, or deployctl deploy — with --provision, --create, --migrate, --profile, --verify, and --destroy.
Compiler
CompilerPlugin, discoveryKeys, DiscoveryKey, MatchedClass, analyze, contributeGraph, validate, codegen, PluginResults, ResultChannel, ClassIdentity, NameReserver, Manifest, ManifestAssembler.