zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Server framework

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 mode

Causes, 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 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  — unreachable

GET /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 querytoNodeHandler 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);

See Typed Request Context.

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