Troubleshooting
Most first-run problems trace back to a handful of root causes. Because Heximon wires your app at build time, many of them surface as diagnostics in your dev console — a red message naming the class and the missing piece — before your app ever runs. Read those first; they usually tell you exactly what's wrong.
If you're just getting your first app running, start with the next few sections — they cover the problems you're most likely to hit before your first route even responds.
My controller's routes don't appear
A controller becomes part of your app by being listed in a module, not by living in the right folder.
Heximon finds a controller because some module names it under http: { controllers } — so a controller that
isn't listed is invisible, and its routes never register.
export class UsersModule extends Module({
providers: [UsersRepository],
http: { controllers: [UsersController] }, // ← a controller is found here, not by file location
}) {}
Walk three checks in order:
- Is the controller listed under
http: { controllers: [...] }in some module? - Does that module reach your root module through
imports? Only modules connected to the root are compiled — a stranded module is silently skipped. - Does the class actually declare
Controller<...>—implements Controller<...>orextends Controller<...>? A class listed undercontrollersthat declares neither is reported as a warning, and its routes are dropped.
"no provider for 'X'"
A constructor parameter is a dependency declaration — Heximon reads the parameter's type and looks for a class it can construct in the module's visible scope. This error means it found a parameter whose type isn't reachable, so there's nothing to inject. The build points straight at the offending parameter and tells you the fix:
error [resolve/no-provider] UsersService (in UsersModule): no provider for 'UsersRepository'
╭─[src/users/users.service.ts:3:39]
│
3 │ constructor(private readonly users: UsersRepository) {}
│ ───────────────
│
╰─ help: add a provider for it (or a class that extends/implements it).
Check, in order:
- Is
Xin theprovidersof the same module — or of a module this oneimports? - If
Xlives in another module, does that module export it (exports: [X]), and does your module import that module? Visibility flows alongimports/exports, never by reach. - Is
Xan abstract or framework-supplied token with no binding? Bind it with{ provide: X, useClass: ... },useFactory, oruseValue, or supply it at boot for tokens the host provides (CoreConfig,InternalClient). - Is the parameter a real class, not an erased type? Class identity is the only token Heximon has — a
parameter typed as an interface or a
typealias has no runtime value to wire.
If X is a token you app.get from a published package (a precompiled, manifest-bearing module you
list in imports), the cause may be a stale manifest — its heximon.manifest.json exporting a token
the shipped package no longer ships.
The app build warns about that at compile time (boundary.manifest-drift); see
the drift check. If you see that
warning, rebuild/republish the package or align your installed version with its manifest.
A request that should pass fails with a 400
Validation runs at the boundary, before your handler. When you call a validated accessor
(readValidatedBody(), getValidatedQuery(), …) and the payload doesn't match the route's schema, it
throws a RequestValidationError, and the built-in filter maps it to a 400 application/problem+json.
That's the point: your handler only ever sees data that already matched its schema.
The 400 body's issues array names exactly which fields failed — read it first. If the request looks
right but is still rejected, check the schema side:
- Does the body actually satisfy the schema referenced in the route's slot
(
Post<"/", { body: CreateUser }>)? - Is
CreateUserimported as a value, notimport type? Heximon wires the validator from the class itself (typeof createUserworks the same way for a plain runtime constant), so a type-only import gives it nothing to validate against.
My endpoint returns "Heximon build failed" after an edit
You changed a source file and now every request answers with a 500 whose body is a build error — often a
codeframe naming a class and a missing piece. That's the dev server telling you the truth: your latest edit
didn't compile, so rather than keep serving the old build as if nothing changed (a confident 200 from code
that no longer matches your source), it surfaces the failure on the request. The same codeframe is in your dev
terminal.
Read the error, fix the source, and save — the next clean compile swaps in the new wiring automatically and the endpoint recovers. Your app process never went down; only requests during the broken window were answered with the error.
A middleware hangs or short-circuits unexpectedly
A middleware is one link in a chain, and it owes the chain exactly one of two things: await next() to
continue (returning the downstream Response), or its own Response to short-circuit. Do neither and
the request never resolves — that's the hang.
public async handle(action: HttpAction, next: Next): Promise<Response> {
if (!action.request.headers.get("authorization")) {
return HttpResponse.json({ error: "unauthorized" }, { status: 401 }); // short-circuit
}
return next(); // continue down the chain
}
Middleware are singletons, constructed once and reused for every request — so never stash per-request
state on an instance field. Two requests in flight would clobber each other's value. Read and write
request-scoped state through the ambient Context instead.
Deeper diagnostics
The rest cover cases you'll hit as your app grows into contracts, scopes, and published packages — less common on a first run, but worth knowing where to look when they show up.
"the contract does not resolve to an importable value"
A contract controller binds its contract as the Controller<SomeApi> type argument, and the generated
registration imports that SomeApi value by reference. If the contract isn't imported in the controller
file — a removed, renamed, or commented-out import — the build fails with discovery/unresolved-reference, a
codeframe underlining the contract name:
error [discovery/unresolved-reference] TasksController: the contract 'TaskApi' does not resolve to an
importable value — 'TaskApi' is not imported into this file. Its wiring would bind to `undefined` at boot.
╭─[src/tasks/tasks.controller.ts:29:33]
╰─ help: import the contract into this file, e.g. `import { TaskApi } from "./…";` — a removed or renamed
import leaves nothing to wire.
The same discovery/unresolved-reference error covers every name a build reads by value, not only a
concept's own type argument — an EventHandler<OrderCreated>, a CommandHandler<CreateOrder>, a contract, a
class a concept references (a controller's middlewares / errorFilters, a streaming handler's
{ durable: Room }, an inline route schema).
It also covers an entry in module config (imports, providers, or a http: { controllers: […] }
namespace list), and a constructor's injected token (constructor(private users: UsersRepository)).
For a referenced class that has its own check, the unresolved error wins: an unimported
middlewares: [ApiAuth] reads as the middleware 'ApiAuth' does not resolve … is not imported, not the
misleading "must extend or implement Middleware"; an unimported constructor token reads as
the dependency 'UsersRepository' does not resolve … is not imported, not the misleading no provider for 'UsersRepository'.
So a missing import is always a build error naming what didn't resolve and the fix, never a silent runtime miss.
The fix is always to import (or, for a same-file contract, export) the contract:
import { TaskApi } from "./task.api"; // ← required: the contract is imported by reference at runtime
export class TasksController implements Controller<{
contract: TaskApi;
middlewares: [ApiAuthMiddleware];
}> {
// … one handler per TaskApi route key
}
pnpm check flags the same mistake as a Cannot find name 'TaskApi' type error (the concept clause
references the type). Should a contract slip through compilation anyway — e.g. its import resolves to a module
that no longer exports it — the boot fails with the runtime guard's equivalent message
(A contract-mode controller has no usable contract at boot …) naming the same fix, so the mistake is never a
bare undefined dereference.
"type argument … the compiler cannot read statically"
The compiler reads each concept clause's type argument statically — it never type-checks, so it follows
only what it can see literally at the implements/extends clause: an inline string (Controller<"/users">),
an inline object literal (Controller<{ prefix, middlewares }>), or a class reference (EventHandler<OrderCreated>).
A named type the argument resolves through — a type alias, a union, a conditional, a typeof, a generic
parameter — is opaque to it.
type SecuredConfig = { prefix: "/secured"; middlewares: [AuthMiddleware] };
export class SecuredController implements Controller<SecuredConfig> {} // ✗ config can't be read
export class SecuredController implements Controller<{ prefix: "/secured"; middlewares: [AuthMiddleware] }> {} // ✓
Earlier this failed silently — the controller wired with no prefix or middlewares, the handler bound to no event, the Durable Object ran with default storage. It is now a build error that underlines the offending argument and names the consequence.
The fix is always the same: inline the binding at the concept clause
(http.controller-config-unreadable, events.event-type-unreadable, cqrs.message-type-unreadable,
durable.config-unreadable).
A shipped concept from a package won't wire
When you list a concept class that a published package ships — a HealthIndicator, a Middleware, a
Durable Object — directly under its namespace (rather than a class from your own source), Heximon recovers
that class's base and constructor dependencies from the package's published .d.ts. If those declarations
can't be read, the build fails with resolve/external-concept-unresolvable:
error [resolve/external-concept-unresolvable]: DatabaseHealthIndicator is listed under a concept
namespace but its type declarations in "@acme/health" could not be read: "./indicator.d.ts" could not be
parsed (Unexpected token) — the published package "@acme/health" ships a corrupt .d.ts; reinstall or
rebuild it. List a local subclass that delegates to it, or ensure the package ships types.
There are two distinct causes, and the message tells you which one you have:
- The package ships no types — the message reads "ships no usable type declarations". The package has
no
types/typingsentry, so there is nothing to recover. Use a package that ships types, or wrap the class in a local subclass that delegates to a DI-injected service. - The package ships a corrupt
.d.ts— the message names the exact file and the parse error ("./indicator.d.ts" could not be parsed (Unexpected token)). A published.d.tsthat the compiler can't parse almost always means a broken or half-written install — reinstall or rebuild the package (pnpm install --force, or rebuild it when it's a local workspace dependency).
If you only subclass the shipped class in your own source (you list your own
class AppProbe extends DatabaseHealthIndicator), the same corrupt-.d.ts cause surfaces as a warning
naming the file rather than an error — your subclass just isn't recognized as that concept until the
package's types are readable again.
My response shape is wrong but nothing errors
Heximon never throws on a response-body mismatch at runtime — sending the wrong shape compiles and runs, by design, so a logging slip can't take an endpoint down in production.
In contract or schema-typed routes it instead lights up where it's cheap to fix: the handler's return type
is checked against the route's responses, so run pnpm check and the type error points at the mismatched
route. (With the HEXIMON_DEBUG environment variable set, the dev server also logs a debug line as the
route is hit.)
Route scopes aren't enforced
A guard reads the resolved scope union off action.scopes. A route that declares no scopes has an
empty union, which passes any guard — so an unguarded route is treated as public, on purpose: it lets
public and protected routes coexist under one controller. If a route you meant to protect is wide open,
it's missing its declaration.
create: Route.post("/").scopes("users:write").body(createUser).responses({ 201: createdUser }),
The scopes you declare on the route fold into the union the guard reads, so adding one is all it takes to bring the route under the guard.
Context reads throw outside a request
Context is backed by per-operation async storage — context.get / set / waitUntil only work
inside an active frame. HTTP requests are wrapped in one for you, but a background task, a script, or a
test that calls into context-using code without opening a frame will throw, because there's no frame to
read from.
Open one around the entry point:
await context.run(async () => {
context.set("tenantId", "test-tenant");
await doWork();
});
This is the same primitive the HTTP transport uses per request — you only call it yourself for the entry points Heximon doesn't already wrap.
Type errors from workspace-linked @heximon packages
This one is monorepo-only — a normal pnpm add @heximon/http install never hits it. If you develop
Heximon itself, or link @heximon/* packages by source in a workspace, your editor and pnpm check
resolve those packages through the development export condition, which points at package src/ instead
of published dist. Vite's dev server needs to be told to resolve the same way, or it loads a different
build than your type-checker sees.
Add the condition to resolve.conditions in vite.config.ts:
import heximon from "@heximon/build/vite";
import { defaultServerConditions, defineConfig } from "vite-plus";
export default defineConfig({
plugins: [heximon()],
resolve: { conditions: ["development", ...defaultServerConditions] },
server: { port: 3000 },
});
defaultServerConditions (from vite-plus) keeps Vite's normal server-resolution conditions in place;
prepending "development" is what makes a linked @heximon/* package resolve to its source. A regular
installed dependency never needs this — it has no development condition to chase in the first place.
The generated-directory tsconfig entry
The first vp dev / vp build adds "src/.heximon/**/*" to your tsconfig.json include for you —
the entry that surfaces compile verdicts in your editor.
The one case it can't fix: an include inherited from a shared base config your tsconfig.jsonextends — the healer won't override an include it doesn't own. Add the entry to that base config
by hand.
Still stuck?
- Installation — re-check the Vite config and the decorator-free TypeScript setup that lets the compiler read your modules.
- Dependency Injection — how providers,
imports, andexportsdecide what's resolvable where. - Compiler Diagnostics — how to read any build error: the code, the
codeframe, and the
help:fix. - Validation — runtime schemas, the validated accessors, and the
400problem+json they produce. - Examples — every one of the runnable apps compiles and runs, so they're a working reference for any pattern you're unsure about.
- Search the GitHub issues or open a discussion.
Where Next?
A goal-to-package map for everything past the Quick Start — contracts, databases, auth, background jobs, real-time, and deployment.
Modules & DI
Group a feature with Module(...), wire providers with constructor injection, share them via imports and exports, and let the editor catch a broken DI graph before you build.