Heximon Logo
Under the Hood

Compiler Diagnostics

How a Heximon build error reads — the severity header, the area/slug code, the source codeframe, the help trailer, the dev-server overlay, and the compact CI form.

When the compiler can't wire your app — a constructor asks for a token nothing provides, a config key is misspelled — it doesn't fail with a vague one-liner. It points at the exact line, underlines the offending token, and tells you how to fix it. Reading that output is the fastest way to fix a build, so it's worth knowing its anatomy.

The anatomy of an error

Inject a token no module provides and the build stops with this:

Four parts, each doing one job:

  • The headererror [resolve/no-provider] <message>. The severity comes first, then the stable [area/slug] code, then the problem stated in one line — no remedy crammed in.
  • The codeframe — opened by ╭─[file:line:col], it shows the offending line in context and underlines the exact span. Here it points past the parameter name to the typeUsersRepository is the token that can't be satisfied, so that's what's underlined.
  • The help trailer╰─ help: … closes the frame with the remedy. The fix lives here, separate from the problem, so you can read what's wrong and what to do as two distinct thoughts.
  • The summary — a closing Found N errors, M warnings in K files. line after all diagnostics.

The location in the header is a real file:line:column; in a terminal that supports it, it's a clickable link straight to the offending line.

Several spans at once

A diagnostic isn't limited to one underline. A syntax error renders in the same frame as a wiring error, and when the compiler can point at more than one place, it does — an unclosed brace underlines both where a } was expected and where the block was opened:

The primary span — here, where the } was expected — is drawn in the severity color and anchors the file:line:column header; secondary spans (Opened here) are drawn in blue. A span that runs across several lines, such as an unterminated string or template literal, underlines every line it covers.

In the dev server

pnpm dev shows the same codeframe in the terminal the moment a compile fails.

Because Heximon is a backend framework, the failure also surfaces on the request itself: while the latest compile is broken, the dev server answers every request with the build error instead of quietly serving the last build that compiled — so a curl, a REST client, or a separate frontend hitting your API sees the failure, not a confident 200 from code that no longer matches your source.

The response is content-negotiated from the request's Accept: a browser navigation gets a colored, terminal-look HTML page (the same codeframe rendered to CSS-themed markup), a JSON client gets application/problem+json, and a terminal client gets the plain codeframe.

That HTML page is the single error surface, and it stays live on its own: it loads Vite's HMR client, so it reloads itself the moment the next compile is clean — you fix the source, the page swaps to your working app with no manual refresh.

Every subsequent compile error — the second, the third, even one with the same message in a new place — re-renders the page in the same styling rather than leaving it stale, and it hides Vite's generic overlay so you never see two error panels stacked on top of each other.

Your running app is not torn down by a failed recompile: the process and the resources it holds (open connections, timers) stay alive — only incoming requests are answered with the error, so you never lose app state to a typo mid-edit. Fix the source and the next clean compile swaps in the new wiring and reloads.

A JSON preview when the build is clean

The error page has a clean-build counterpart. Because Heximon is a backend, opening an API route in a browser normally dumps a wall of raw JSON.

When the build is clean and a browser navigation lands on a JSON response (application/json or any +json), the dev server instead returns a JSON preview page: the body pretty-printed and syntax-highlighted in the same palette as the error page, with a shallow footer reporting the request line, the status (a colored dot — green 2xx, red 4xx/5xx), the content type, the route and compile durations, the package name, and the ⏣ Heximon brand.

It loads the same hot-reload client, so editing your source reloads it (re-running the request), and a compile that breaks flips it straight to the build-error page — then back to the preview once you fix it.

The preview is purely a browser convenience: it is keyed on Accept: text/html, so a fetch, a REST client, or a curl still receives the raw API response — its exact headers, status, and streaming body — untouched. You see a readable page; your code sees the real bytes.

In CI and editors

Output to a non-interactive stream — a CI job, a piped log — drops the box drawing for one plain line per diagnostic, in a file:line:column: severity [code]: message shape an editor or CI problem-matcher can parse:

src/users/users.service.ts:3:39: error [resolve/no-provider]: UsersService (in UsersModule): no provider for 'UsersRepository' — add a provider for it (or a class that extends/implements it).
Found 1 error in 1 file.

The remedy that the terminal shows as a help: trailer is appended after an em-dash here, so the single line carries the whole message.

Error verdicts also reach the editor between builds: each compile writes src/.heximon/graph/verdicts.gen.d.ts, whose augmentations re-report every error at the offending module's own class declaration (or, for a dirty precompiled module, at the imports entry that pulls it in).

The first compile adds the generated-directory include entry to your tsconfig.json for you. See the modules page for how this reaches your editor.

The diagnostic code

Every diagnostic carries a stable area/slug code — resolve/no-provider, config/unknown-key, graph/module-import-cycle. It names the kind of problem independently of the exact wording, so you can search for it, recognize a recurring class of mistake, or match on it in tooling without depending on a message that may be reworded.

The area tells you which part of the build raised it (resolve is dependency resolution, graph the module/import graph, config a module-config key, and so on).

A precise no-provider remedy

When a token has no provider, the help: trailer is tailored to how the token is reachable, so the fix is exact rather than generic:

  • An imported module already has it, unexported. If your module imports SharedModule and a class there satisfies the token but SharedModule doesn't export it, the remedy names that module: a satisfier for 'Logger' exists in the imported module 'SharedModule' but isn't bound here; export 'Logger' from 'SharedModule'.
  • A module owns it but doesn't export it. If a module provides the token but neither exports it nor is imported here, the remedy is to export it from its owner first: 'Logger' is provided by 'SharedModule' but 'SharedModule' does not export it; add 'Logger' to 'SharedModule''s exports and import 'SharedModule'.
  • A module exports it. The plain seam — import 'SharedModule'.
  • No module has it. add a provider for it (or a class that extends/implements it).

These remedies assume the token itself is importable — a name your code actually imports. When the constructor names a token that isn't importable (not imported into the file, or a same-file class that isn't exported), there is no provider that could ever satisfy that phantom identity, so the build reports the discovery/unresolved-reference error below instead — naming the missing import, not a missing provider.

An unresolved concept reference

Every discovered concept binds a name from your source — EventHandler<OrderCreated>, CommandHandler<CreateOrder>, implements Controller<TaskApi>. The compiler reads that name statically (it never type-checks). When the name isn't imported — a removed, renamed, or commented-out import — the build fails with one shared error, discovery/unresolved-reference, a codeframe underlining the name:

error [discovery/unresolved-reference]  OrderCreatedHandler: the event 'OrderCreated' does not resolve to an
importable value — 'OrderCreated' is not imported into this file. Its wiring would bind to `undefined` at boot.
  ╰─ help: import the event into this file, e.g. `import { OrderCreated } from "./…";`.

The wording is identical across concepts (the noun — event, command, contract — is the only thing that changes), so a missing import is always a build error that names the concept and the fix.

This matters most for the identity-keyed concepts (events, commands, queries, domain events): without it, an unimported binding isn't a crash — the handler is built but its dispatch key silently never matches, so it just never fires.

pnpm check catches the same mistake earlier as a Cannot find name type error; this is the dev server's backstop, since its oxc compiler does not type-check.

The same error covers every place a build reads a class name by value, not just a concept's own type argument:

  • A class a concept references — a controller's middlewares / errorFilters, an ErrorFilter<E>'s caught error type, an inline route schema (Post<"/", { body: CreateUser }>), a streaming handler's { durable: Room } host or { events: FeedEvents } map. These each have their own downstream check ("is not a discovered Middleware", "must extend DurableObject", …), but an unimported name is reported as the unresolved reference first — so you see the middleware 'ApiAuth' does not resolve … is not imported rather than the misleading "must extend or implement Middleware", which would send you to fix the base class instead of the import.
  • A class listed in module configimports, exports, providers (bare or { provide, useClass }), and every plugin-namespace entry (http: { controllers: [UsersController] }, events: { handlers: […] }). An unimported entry there is the highest-impact mistake: an imported module would crash at boot, and a namespace entry would simply never be discovered (no route, no dispatch). The codeframe names the owning module and underlines the offending entry.
  • A constructor's DI tokenconstructor(private users: UsersRepository). A token a class injects must be imported (class identity is the only token). When it isn't, no provider can satisfy that phantom identity, so the build reports Svc: the dependency 'UsersRepository' does not resolve … is not imported rather than the generic no provider for 'UsersRepository' — which would otherwise send you to add a provider for a token you simply forgot to import.

So whether the missing import is a concept's type argument, a class another concept references, an entry in a module's config, or a constructor's injected token, the dev server fails the build with the same discovery/unresolved-reference codeframe that names what didn't resolve and how to fix it.

A concept base that isn't the framework's

There's one more name a build reads by value: the base class a concept binds. A controller is a controller because it extends or implements Controller<…>; a handler because it extends or implements EventHandler<…>.

The compiler recognises the concept by walking both the extends chain and any implements clause at every hop to the framework base it declares (Controller from @heximon/http, EventHandler from @heximon/events, …). When neither reaches one of them, the class is listed but never wired — no route, no dispatch entry.

Most of the time that's a genuine "this isn't a concept" — handled by the config/orphan-concept and inert-provider warnings below.

But when the heritage clause literally names the framework base (extends Controller<"/users">) yet that Controller doesn't resolve to @heximon/http — because it's not imported, or imported from the wrong package — the author clearly meant the framework concept.

That's a missing or wrong import, not a deliberate non-concept, so the build fails with config/misresolved-concept-base:

error [config/misresolved-concept-base]  UsersController (listed under http.controllers) declares Controller
in its heritage, but that name does not resolve to "@heximon/http" (the Controller concept base) — it is not
imported in this file. It will be constructed as a provider but never wired as a Controller.
  ╰─ help: import { Controller } from "@heximon/http".

Without it the controller compiles cleanly and is even built as a provider — but its routes silently never register, with a green build.

pnpm check catches the same mistake earlier as a Cannot find name 'Controller' type error; this is the dev server's backstop, since its oxc compiler does not type-check.

The check is shared across every discovery namespace, so the identical error guards an unimported EventHandler, CommandHandler, QueryHandler, QueueEventHandler, HealthIndicator, and the rest.

A streaming config member that isn't a bare class reference

A WebSocketHandler / ServerSentEventsHandler carries its wiring in the heritage clause's type argument — the hibernation/stream host (durable), the auth-at-connect chain (middlewares), the SSE broadcast map (events), and the WebSocket inbound-frame schema (body). Each of those names a class, and class identity is the only DI token, so each must be written as a bare class reference the compiler can resolve to an import:

export class ChatSocket implements WebSocketHandler<{
  path: "/ws/chat";
  durable: ChatRoom;            // ✅ a bare class reference
  middlewares: [AuthMiddleware];
}> {}

A member that is present but written in a shape that can never resolve — a namespace-qualified name (durable: rooms.ChatRoom, body: schemas.ChatMessage), an inline object type (events: { … }), or a non-tuple middlewares (a type alias instead of a [A, B] literal) — is a streaming/reference-unreadable build error rather than a silent drop:

error [streaming/reference-unreadable]  WebSocketHandler 'ChatSocket': the `durable` host is not a bare class
reference; name the class directly (class identity is the only DI token, so a namespace member, inline type, or
non-tuple list cannot resolve).

Without it the handler would compile cleanly while the host, middleware (so auth-at-connect silently vanishes), events map, or frame schema dropped. The check is shared across both streaming transports. A transparent readonly / parenthesized wrapper is not an error — middlewares: readonly [AuthMiddleware] is peeled and reads identically to the bare tuple.

An inline route schema slot that isn't a bare class reference

An inline route schema slot — body/query/params/responses[status] on Post<"/", { body: … }> (or the equivalent typeof form) — binds a schema by value, so it too must resolve to a single importable class:

export class ProductsController implements Controller<"/products"> {
  async create(action: Post<"/", { body: CreateProduct }>) {}   // ✅ a bare class reference
}

A slot written in a shape that can never collapse to one class — a generic instantiation (body: Paginated<Product>) or a namespace-qualified name (body: Schemas.CreateProduct) — is http/schema-reference-unreadable rather than a silent drop:

error [http/schema-reference-unreadable]  ProductsController: the 'body' schema slot references
`Paginated<...>`, which the compiler cannot resolve to a single importable value — a route schema slot
must name a bare schema class the compiler can import for runtime validation, not a generic instantiation
or a namespace-qualified name.
  ╰─ help: name the schema class directly, e.g. `body: CreateProduct` — or `typeof CreateProduct` for the
schema class value.

Without it the controller would compile cleanly while the slot silently bound nothing, crashing on the first request that reaches it. The check runs on every schema slot in a route — body, query, params, and each responses[status] entry alike.

Every config read either reads faithfully or fails loud

The pattern above — a config value the compiler can't read statically must not be silently dropped — runs through every reader.

Two complementary moves enforce it: a transparent wrapper (readonly, parenthesized (…)) is peeled and read faithfully, and a genuinely unreadable form (a union, a keyword, a type alias, a conditional, a spread) is a build error or warning naming the exact spot — never a silent default. The same mental model covers these readers:

  • Handler retry options (CommandHandler<Cmd, { retry: { attempts } }>, queue + integration handlers). A negative (attempts: -5) or parenthesized ((5)) number reads faithfully; a non-literal field (a union 3 | 5, the keyword number, a type alias) warns with retry/non-literal-field and falls back to the runtime default (rather than silently doing so).
  • Module config lists (imports / providers / a namespace sub-key). An element that isn't a bare class identifier, a new Module(config), or a { provide, use… } object — a conditional cond ? A : B, a spread ...mods, a call — is config/list-element-unanalyzable (it would silently drop a class from the graph). A token declared twice in one providers array warns with config/duplicate-provide-token (last-wins):
    warning [config/duplicate-provide-token]  AppModule: provider token 'Database' is declared more than once in
    'providers' (first 'PostgresDatabase', then 'SqliteDatabase') — each token must have exactly one binding;
    remove the duplicate or merge into one entry.
    
  • DurableObject<{ storage, binding, hibernate }> config. A present-but-unreadable member (a storage union, a template-literal / empty binding, a non-literal hibernate) is durable/config-member-unreadable; a key outside the three (a typo like hibernat) is durable/config-unknown-key. hibernate: (true) is peeled and read.
  • CQRS { override }. override: (true) is read; a non-literal override (a union, a type alias) is cqrs/override-unreadable — surfaced instead of the confusing duplicate-handler error it used to cause.
  • Event keys. A tuple mixing string keys and class references (EventHandler<["a", OrderCreated]>) is events/mixed-tuple (the string key would be dropped); an empty "" key is events/empty-string-key; a repeated tuple key warns with events/duplicate-tuple-key and is deduplicated. An IntegrationEventHandler with an empty/blank type is integration/empty-channel.

A Route builder's .cache({ … }) / .cors({ … }) directives are read at runtime (by @heximon/openapi / @heximon/mcp), so a misspelled key there throws at route-build time (app startup) — Route.cache() received unknown option key(s): "stalMaxAge" — rather than being silently ignored on the wire.

A declared capability with no plugin fails the build

One config mistake is promoted above the warning tier because nothing else would ever surface it: a populated first-party namespace whose compiler plugin is not active.

Write cqrs: { commandHandlers: [PlaceOrder] } with no CqrsPlugin in heximon.config.ts and the build stops with config/namespace-plugin-missing — the hint names the exact fix (add `new CqrsPlugin()` (from "@heximon/cqrs/compiler") to the plugins list in heximon.config.ts, or remove the 'cqrs' key).

Without this error the listed classes would silently never be discovered or wired — the "my handler never fires" mystery. A mere sub-key typo under an active plugin stays the config/unclaimed-concept-key warning below.

Warnings worth heeding

Some checks warn rather than fail the build — the compile still succeeds, but the warning flags a structural smell that almost always means a mistake. All three are advisory by default; opt any into a hard error with strictDiagnostics (see below).

  • config/orphan-concept — a concrete class extends or implements a registered concept base (a Controller, EventHandler, command/query handler, queue handler, …) but is listed in no module config. It is parsed but never discovered, so nothing wires it — no route, no dispatch-table entry. Almost always a forgotten registration; the hint is a paste-ready fix naming the exact sub-key, e.g. add `events: { handlers: [OrderHandler] }` to the owning module's config. (Referenced-not-listed concepts like Middleware — named in a controller's middlewares annotation — are exempt; an abstract intermediate base is too.)
  • config/duplicate-provider-ownership — the same class appears in two modules' providers within one app's import graph. A provider belongs to exactly one owning module; otherwise which module constructs it (and which app.get returns) is order-dependent. Keep it in one module and import that module from the others. (Listing a shared provider in two different apps' modules is fine — each app composes its own instance.)
  • config/cross-module-internal-import — a file owned by one module imports, as a runtime value, a class another module owns but does not export — reaching across a module boundary into another module's internals. The fix is one of: make it an import type if the reference is type-level (type-only imports are exempt — they erase at build time), export the class from its owning module, or move a genuinely shared symbol to a neutral shared location (see below).
  • boundary/manifest-drift — a precompiled, manifest-bearing package you list in imports ships a heximon.manifest.json declaring a core token (its module class, the create<Module> factory, or an exported token) that the package's published .d.ts no longer exports — a stale manifest that would otherwise compile cleanly then fail at runtime with a bare no provider. Rebuild/republish the package, or align your installed version with its manifest. Reported only when the package's .d.ts was read and the name is definitively absent — a package shipping no types is skipped (no evidence). See library mode.

The shared-events convention

A domain event one module raises and another reacts to is genuinely shared — neither module owns it exclusively. Put it in a neutral shared/ location that no feature module lists in its config, and have both modules import it from there.

Because the shared file belongs to no module, importing it crosses no boundary, so config/cross-module-internal-import never fires. This keeps a feature module from reaching into another's internals while still letting a cross-module event flow through the domain-event tier.

Escalating a warning to an error

To enforce a boundary in CI, list its code in strictDiagnostics — in the heximon() plugin options or heximon.config.ts — and the build fails on it:

// heximon.config.ts
import { DiagnosticCode, defineHeximonConfig } from "@heximon/build";
import { HttpPlugin } from "@heximon/http/compiler";

export default defineHeximonConfig({
  plugins: [new HttpPlugin()],
  strictDiagnostics: [DiagnosticCode.CrossModuleInternalImport, DiagnosticCode.OrphanConcept],
});

The silence-direction mirror is suppressDiagnostics: list a code there to demote it below a warning, for a codebase that does the flagged thing deliberately. Both fields live in the same three places — the inline heximon({ … }) argument, the heximon Vite config key, and heximon.config.ts — which carry the identical option set (a higher layer overrides a lower one per field).

See also

  • Logging & Observability — the runtime counterpart: what your app writes once it's running.
  • Providers & DI — why a token has no provider, and the providers/imports/exports that fix it.
  • Compiler — writing a plugin that emits its own span-anchored, help-bearing diagnostics.
  • Troubleshooting — the most common build errors and their fixes, by symptom.
Copyright © 2026