15. Typed client
Status: Runnable from 0.3.0.
avi devupdatingapi.gen.ts,avi build,api.events(), numbers in queries and the declarations for a Vite frontend need 0.6.0 or newer;ResponseOfand the other type helpers need 0.7.0.
Your routes already know their paths, schemas and results. avi client turns that into one Api type, and @avijs/client calls your API with it: paths autocomplete, a wrong body fails to compile, responses are typed. It works the same for decorators and the explicit API.
import { createClient } from '@avijs/client';
import type { Api } from './api.gen.js';
const api = createClient<Api>({ baseUrl: 'http://localhost:3000' });
const user = await api.get('/users/:id', { params: { id: 1 } });
// ^? { id: number; name: string }
await api.post('/users', { body: { name: 'Shiela' } });
await api.post('/users', { body: {} }); // compile error: name is missingGenerate the Api type
Projects from npm create avi include src/api.gen.ts, and npm run dev keeps it up to date: add, remove or move a route, save, and the file changes before the app restarts.
2:41:07 PM - File change detected. Starting incremental compilation...
src/api.gen.ts updated: 4 routes
2:41:07 PM - File change detected. Starting incremental compilation...
2:41:08 PM - Found 0 errors. Watching for file changes.
Restarting 'dist/main.js'avi dev writes the file only when routes change; saving a service or a DTO leaves it alone. Pass --no-client to turn this off, or --client <file> to keep another file. Outside avi dev:
npm run client # avi client: writes src/api.gen.ts once
npx avi client --watch # updates it on every save, without compilingnpm run build (avi build) updates it too, so a build never has a stale file.
// src/api.gen.ts, generated by avi client
import type { MethodEndpoint } from '@avijs/core';
import type { UsersController } from './modules/users/users.controller.js';
export interface Api {
'GET /users': MethodEndpoint<'/users', UsersController['findAll']>;
'POST /users': MethodEndpoint<'/users', UsersController['create']>;
'GET /users/:id': MethodEndpoint<'/users/:id', UsersController['findOne']>;
}The file points at your controllers instead of copying their types, so it stays exact and only changes when routes do: change a DTO or a handler and the client follows on the next compile.
avi client finds the app from AviFactory.create(...) or app.register(...). Pass --root src/app.module.ts#AppModule if it cannot, and --out to write elsewhere. In CI, avi client --check fails when the file is out of date.
Where the types come from
| Part | From |
|---|---|
params | The params schema's input, else the path: /users/:id takes { id } |
query | The query schema's input; any query when there is no schema |
body | The body schema's input; any body when there is no schema |
| Response | The response schema's output, else what the handler returns, as JSON |
Request types are what the schema accepts, before transforms: a body schema that turns '2' into 2 takes a string. Query values travel as text, so where a query schema takes any string the client also takes a number or a boolean: query: { limit: 100 }.
Responses are what JSON makes of the result: a Date arrives as a string, undefined fields are optional, reply(body, { status }) gives body's type. Streams and sse() return the Response.
For decorators, the schema reaches the client through RouteContext:
@Post('/', { schema: create })
create(ctx: RouteContext<typeof create>) { ... } // the client sees create.bodyWith ctx: Context, the route still gets a typed response, but not a typed body; avi client warns about it (AVI105).
What avi client cannot see
It reads your code without running it, so it needs routes it can follow: string literal paths and prefixes, controllers in exported consts or classes, and route lists written as arrays. Anything else is left out with a warning that says how to fix it:
| Code | Means |
|---|---|
| AVI101 | No app found: pass --root |
| AVI102 | A controller is not exported, or is written inline: export it as a const |
| AVI103 | A path, prefix or route list is computed: use literals |
| AVI104 | A module cannot be read statically |
| AVI105 | A decorator route has a schema, but ctx is not a RouteContext |
Calling the API
const api = createClient<Api>({
baseUrl: 'https://api.example.com',
headers: () => ({ authorization: `Bearer ${getToken()}` }), // runs per request
init: { credentials: 'include' }, // more fetch options for every request
});
await api.get('/tasks', { query: { status: 'doing' }, signal: AbortSignal.timeout(5000) });Every method (get, post, put, patch, delete, head, options) takes the route's path as written on the server and an options object with params, query, body, headers and signal. Objects are sent as JSON; FormData, strings and bytes as they are.
Path parameters are percent-encoded, so a value cannot add segments to the path. Values that would change the route throw a TypeError instead of being sent: . and .. (URLs resolve them, so /users/../posts would call /posts), and an empty value (/users/ is the route /users, so deleting user "" would hit the collection). baseUrl cannot have a query or a fragment.
A status outside 2xx throws an AviClientError:
import { AviClientError } from '@avijs/client';
try {
await api.get('/users/:id', { params: { id: 404 } });
} catch (error) {
if (error instanceof AviClientError) {
error.status; // 404
error.body; // { statusCode: 404, error: 'Not Found', message: 'User not found' }
error.requestId; // matches the server's logs
}
}api.raw.get(...) returns the Response untouched and never throws for a status.
@avijs/client has no dependencies and runs anywhere fetch does: browsers, Node.js, Bun, Deno and workers.
Naming the types
To name a type in your frontend, such as a component's props, pick it out of Api by its key:
import type { BodyOf, ResponseOf } from '@avijs/client';
type Team = ResponseOf<Api, 'GET /teams'>[number];
type NewTask = BodyOf<Api, 'POST /projects/:projectId/tasks'>;QueryOf and ParamsOf work the same way.
Live events
api.events(path, options) reads a server-sent event stream. It uses fetch, so the client's headers go along, which the browser's EventSource cannot do for a token:
const controller = new AbortController();
for await (const message of api.events('/projects/:id/events', {
params: { id: 7 },
signal: controller.signal,
})) {
message.event; // 'change', or 'message' when the server named none
const change = JSON.parse(message.data) as TaskChange; // objects arrive as JSON text
}The loop ends when the server closes the stream, when you break, or quietly when signal aborts; in React, abort it in the effect's cleanup. A status outside 2xx throws an AviClientError as other calls do. It does not reconnect: wrap the loop in your own retry if you need that.
Typed tests
Pass the same Api to the test client and use client.api:
const client = await createTestClient<Api>(AppModule);
const res = await client.api.get('/users');
assert.ok(res.ok);
assert.equal(res.body[0]?.name, 'Shiela'); // res.body is typed once ok is checkedclient.api never throws for a status, so tests can check errors the same way. The untyped client.get('/users/1') calls still work alongside it.
A React or Vite frontend
With the API and the frontend side by side:
my-app/
├── api/ # npm create avi
└── web/ # npm create vite, React + TypeScript; npm install @avijs/clientImport Api from the server's compiled declarations, not its source:
// web/src/api.ts
import { createClient } from '@avijs/client';
import type { Api } from '../../api/dist/api.gen.js';
export const api = createClient<Api>({ baseUrl: import.meta.env.VITE_API_URL });src/api.gen.ts points at your controllers, so importing it makes the frontend's compiler check the server's code under the frontend's settings, and it fails: Vite's erasableSyntaxOnly rejects constructor parameter properties, and its DOM types have no Buffer or node: modules. dist/api.gen.d.ts holds only types, which skipLibCheck leaves alone. Projects from npm create avi emit it ("declaration": true in tsconfig.json); add that line to older ones.
Keep npm run dev running in api/: it rewrites dist/api.gen.d.ts on save, and the editor in web/ sees a new route right away. The import is type-only, so no server code reaches the bundle.
If the declaration build fails with TS2742 ("The inferred type of … cannot be named"), give that handler an explicit return type.
A frontend in another repository
The generated file imports your server's code, so it suits a monorepo, where the frontend can import from the server package. For a frontend in its own repository, generate types from your OpenAPI document instead, for example with openapi-typescript.