Requests
Every route has a method shortcut (get, post, put, patch, delete, head, options) that takes the route template as written in the controller, and an options object when the route needs one. request() takes the method as its first argument.
await api.get('/users/:id', { params: { id: '42' } });
await api.request('PATCH', '/users/:id', {
params: { id: '42' },
body: { name: 'Ada' },
});
Path parameters
params fills the :segments of the template, encoded with encodeURIComponent. A wildcard segment such as **:path keeps its slashes:
await api.get('/files/**:path', { params: { path: 'reports/2026/q3.pdf' } });
// GET /files/reports/2026/q3.pdf
Query strings
query is serialized the way Vercube reads it back. null and undefined are left out, a Date is sent as ISO 8601, and an array repeats the key:
await api.get('/users', {
query: { sort: 'name', tag: ['admin', 'staff'], page: undefined },
});
// GET /users?sort=name&tag=admin&tag=staff
On the server a repeated key resolves to its first value unless the parameter opted in with @QueryParam({ name: 'tag', array: true }). An array query parameter is optional in the client, because the server resolves a missing one to [].
Bodies
A body is sent as JSON with content-type: application/json. A Blob, ReadableStream, ArrayBuffer, typed array, FormData or URLSearchParams is passed to fetch as it is, so a route that reads the raw request can still be called:
await api.post('/imports', { body: file }); // a File from an <input type="file">
A @MultipartFormData() route takes form instead of body. A plain object is turned into FormData, with Blob values as uploads and arrays as repeated fields:
await api.post('/users/:id/avatar', {
params: { id: '42' },
form: { file: avatarBlob, caption: 'Me, in 2026' },
});
Headers
Headers given to createClient are sent with every request. A function is called before each one, which is where a token that expires belongs:
const api = createClient<VercubeRoutes>({
baseURL: '/api',
headers: async () => ({ authorization: `Bearer ${await auth.token()}` }),
});
Headers given to a call are added on top. A header the handler reads with @Header('x-tenant') is required by the call's types; any other header is accepted.
Every other fetch option (credentials, cache, keepalive, signal...) is passed through: api.get('/me', { credentials: 'include' }).
Hooks
onRequest runs before a request is sent and onResponse after the response arrives, error responses included. Either may return a replacement:
const api = createClient<VercubeRoutes>({
baseURL: 'http://localhost:3000',
onRequest: ({ request }) => {
request.headers.set('x-request-id', crypto.randomUUID());
},
onResponse: ({ request, response }) => {
metrics.record(request.url, response.status);
},
});
To send requests somewhere other than the global fetch, pass fetch. It receives a Request, so an application's own handler fits: createClient({ baseURL: 'http://app', fetch: (request) => app.fetch(request) }).
Responses
A successful call resolves to the body: parsed JSON when the response says it is JSON, the text otherwise, and undefined when the body is empty or the method is HEAD.
Errors
Any status outside 200 to 299 rejects with a VercubeClientError. Vercube answers errors with a JSON body, which is parsed into body, and its message becomes the error's message:
import { VercubeClientError } from '@vercube/client';
try {
await api.get('/users/:id', { params: { id: 'nope' } });
} catch (error) {
if (error instanceof VercubeClientError && error.status === 404) {
return null;
}
throw error;
}
error.response is the Response itself, with its body already read. A network failure is not a VercubeClientError: it is the TypeError that fetch throws.
Typed errors
Declare what a route can answer with using @Throws(), and the generated route map carries it:
@Get('/:id')
@Throws(NotFoundError)
public async getUser(@Param('id') id: string): Promise<User> {
const user = await this.users.find(id);
if (!user) {
throw new NotFoundError('No such user');
}
return user;
}
@Put('/:id/email')
@Throws(NotFoundError, ConflictError)
public async changeEmail(
@Param('id') id: string,
@Body() body: { email: string },
): Promise<User> {
return this.users.changeEmail(id, body.email);
}
api.safe has the same calls, but resolves to { data, error, response } instead of rejecting. error is a union with one VercubeClientError per declared status, so a switch on error.status narrows error.body:
const res = await api.safe.put('/users/:id/email', {
params: { id },
body: { email },
});
if (res.error) {
switch (res.error.status) {
case 400:
// validation issues, absent for a body that is not JSON
return showIssues(res.error.body.errors ?? []);
case 404:
return null; // { status: 404; name: string; message?: string }
case 409:
return pickAnother(res.error.body.existingId); // the ConflictError's own field
default:
throw res.error;
}
}
res.data; // User
The body type is the error's JSON: its own fields, status fixed to the declared status, message optional because an error created without one has none. A name the class declares as a literal (override name = 'ConflictError' as const) stays a literal.
The generator adds the errors the framework throws itself: a 400 with errors (the validation issues) on a route that reads a body or a form or validates an argument, and a 401 and a 403 on a route behind @Auth(). A 500 is never added to the union implicitly (declare InternalServerError if you want it there). Any route can answer with one, and so with a status nobody declared, such as an error from a middleware: these arrive as a VercubeClientError like the rest, which is why the default branch above rethrows.
A route without declared errors still works with api.safe; its error is a plain VercubeClientError. Network failures and aborts still reject, since the server never answered. api.safe.request() takes the method first, like api.request().
With the throwing calls, api.isRouteError() gives the same narrowing in a catch:
try {
return await api.get('/users/:id', { params: { id } });
} catch (error) {
if (api.isRouteError(error, 'GET /users/:id') && error.status === 404) {
return null;
}
throw error;
}
It checks that the error came from a call to that route template; the status union comes from the declaration.
When the generator cannot import an error class, for example one that is not exported from the controller's file or its imports, it types that status as { status; name; message? } and logs a warning instead of failing.
Server-sent events
An @Sse() route resolves to an async iterable of events, each typed by what the handler yields:
@Sse('/builds/:id/log')
public async *log(
@Param('id') id: string,
): AsyncGenerator<{ event: 'line'; data: { text: string } } | { event: 'done' }> {
// ...
}
for await (const message of await api.get('/builds/:id/log', {
params: { id: '7' },
})) {
if (message.event === 'line') {
console.log(message.data.text);
}
}
data is parsed as JSON when it is JSON, which is how Vercube sends anything that is not a string. A yielded string that happens to be valid JSON, like '42', therefore arrives parsed. Heartbeats are skipped, and unlike EventSource an event without data ({ event: 'done' }) is still delivered. Leaving the loop with break or return closes the connection.
Aborting
signal cancels the request, and for a server-sent event stream, the stream:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
for await (const message of await api.get('/events', {
signal: controller.signal,
})) {
// rejects with the abort reason once aborted
}