Common ErrorsSupported
A guide to the errors this framework actually produces, and the ones that produce no error at all — which are the dangerous half.
is<T>() throws runtime type witness required#
The most important entry on this page. The AOT transformer did not run, and the erased type argument has no runtime witness.
is<{ id: number }>({ id: 'not a number' });
// throws: runtime type witness required in test/fallback modeCauses, in order of frequency: running with --experimental-strip-types or ts-node; a bundler that does not invoke the transformer (esbuild, SWC, Bun, Turbopack, Deno); Metro without withZmdb; or a build that skipped the plugin configuration.
The fix is a test that fails loudly:
it('the transformer is running', () => {
expect(is<{ id: number }>({ id: 'x' })).toBe(false);
});Run it in CI against the built artefact, not the source. See JIT vs AOT and Deployment.
UnresolvedTokenError: <description>#
The container has no registration for that token. Three usual causes:
- The provider is in a module that nothing
imports.compileModulewalks the graph from the root only. - You are resolving from the wrong container — two
createAppcalls give two containers. - The token was created twice.
createToken<T>('POSTS')called in two files gives two distinct tokens with the same description, and they do not match. Export the token from one module.
The error names the token's description, which is why a meaningful description pays off — createToken<Repo>('token') produces an unhelpful message.
@zmdb/app: import cycle in the module graph: AModule -> BModule -> AModule#
Two modules import each other. The message names the full cycle path, including an edge declared with lazy().
Usually the fix is to extract the shared providers into a third module both import, rather than to break the cycle by moving a controller.
A 404 for a route you registered#
Two candidates.
A more general route matched first. Matching is first-match in registration order with no specificity ranking:
router.register(PostsController); // /posts/:id
router.register(AdminController); // /posts/admin — unreachableGET /posts/admin matches /posts/:id with id = 'admin'. Register static paths before parameterised ones. Print the table to see the order:
for (const C of CONTROLLERS) for (const r of getRoutes(C)) console.log(r.method, r.path, r.handlerName);The path is not what you think. @Controller and the method decorator compose, duplicate slashes collapse and a trailing slash is stripped. @Controller('/posts/') plus @Get('/:id') gives /posts/:id, but check rather than assume.
ctx.query is always empty#
The bundled adapters do not populate query — toNodeHandler and toFetchHandler both leave it undefined. Parse it yourself and pass it in:
const url = new URL(req.url ?? '/', 'http://localhost');
const query = Object.fromEntries(url.searchParams);ctx.body is a string when you expected an object#
For application/json, application/*+json, text/*, or a request with no content type, parsing falls back to the decoded string on a JSON.parse failure. Other explicit content types are preserved as bytes; for example, application/x-www-form-urlencoded arrives as a Uint8Array.
Validate at the top of the handler and the failure becomes a 400 instead of a confusing undefined deeper in:
const dto = assert<CreateDTO<Post>>(ctx.body);See Raw Body.
A 500 where you threw a 403#
ChainError(403, …) reaching the router serialises as a 500. A handler throw has only two built-in mappings — 400 when it carries issues, and 500 otherwise — and the status on the error is ignored. Route selection separately returns 400 for an unsupported header version, 406 for an unacceptable media-type version, or 404 for an unknown path; a registered guard returning false returns 403.
Nothing in the router's dispatch path calls runChain, so no ExceptionFilter runs unless your handler ran the chain itself; one that does reaches the client, provided the filter built its response with json, text or respond rather than as a plain { status, body, headers } literal, which serialises as a 200.
Catch it and return the status instead of throwing:
catch (error) {
if (error instanceof ChainError) return json({ error: error.message }, { status: error.status });
throw error;
}See Request Lifecycle.
this.repo is undefined in a handler#
@Inject is a field decorator, and container.build(Ctor) calls new Ctor() with no arguments. Constructor injection does not exist:
// wrong — the parameter is never supplied
constructor(@Inject(POSTS) private readonly repo: PostRepo) {}// right
@Inject(POSTS) private readonly repo!: PostRepo;See Dependency Injection.
Request state from another user appears#
Controllers and providers are singletons — each instance is built once per app, either eagerly or on its lazy module's first load. this.currentUser = … in a handler is a race that serves one user's data to another, and it looks correct in every single-request test.
Keep request state in local variables or a per-request object, never on an instance field.
Every query returns the previous tenant's rows#
set_config('app.tenant', value, false) on a pooled connection persists after the request, and the next request on that connection inherits it. The third argument must be true (transaction-local):
await client.query('SELECT set_config($1, $2, true)', ['app.tenant', tenant]);A cross-tenant data leak with no error. See Request Context.
UnsupportedFeatureError#
The query compiler cannot express something in the target dialect. Check the dialect pages for what each supports; the common cases are features that exist in Postgres and not in SQLite or MySQL.
ValidationError with an empty issues array#
You constructed it that way — new ValidationError('message', []). The router treats anything with an issues property as a 400, so this is the idiomatic way to signal a client error, and an empty array is fine. Validator-produced errors populate issues with paths.
The process will not exit after a script#
An open connection pool holds the event loop. WebApplication is AsyncDisposable:
await using app = createApp(AppModule);
await app.init();Or dispose explicitly, and call pool.end() from whatever created the pool — see Connections and Shutdown, which also covers why a query still queued when the pool ends never settles. For the script shape, see Standalone Applications.
A migration applied twice, or deadlocked#
Migrations were run on application boot with several replicas, each racing the same statement. Run them as a separate deployment step. See Migrations.
---
See also: Request Lifecycle · JIT vs AOT · FAQ