Heximon Logo
Under the Hood

How the Compiler Works

Why Heximon compiles a static DI graph ahead of time instead of resolving it at boot, what the compiler reads from your source, what it emits into .heximon/, and the verdicts.gen.d.ts feedback loop that surfaces DI mistakes in your editor.

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 configsproviders, 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/implements clause 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],
}) {}

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:

heximon.config.ts
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:

tsconfig.json
{
  "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.
Copyright © 2026