Skip to Content
Customizing the Client

TypeScript Client Customization

You can replace the lower-level modules that the generated TypeScript client imports: the fetcher and validateOnClient. Set them in the outputConfig.imports object of the config file. The object also takes createRPC: the module the client imports createRPC from, vovk/create-rpc by default (see config).

With vovk-ajv (described below) for client-side validation, the generated index.ts that creates the UserRPC module looks roughly like this:

./src/client/index.ts
import { createRPC } from 'vovk/create-rpc'; import { schema } from './schema'; export const UserRPC = createRPC(schema, '', 'UserRPC', import('vovk/fetcher'), { validateOnClient: import('vovk-ajv'), apiRoot: 'http://localhost:3000/api', });

With custom outputConfig.imports:

vovk.config.mjs
/** @type {import('vovk').VovkConfig} */ const config = { outputConfig: { imports: { fetcher: './src/lib/fetcher', validateOnClient: './src/lib/validate-on-client', }, }, }; export default config;

The generated index.ts imports them, with relative paths adjusted to its folder. A path to a .ts file is written the way tsconfig.json resolves it: without the extension under bundler resolution, by its .js name under node16 or nodenext.

./src/client/index.ts
import { createRPC } from 'vovk/create-rpc'; import { schema } from './schema'; export const UserRPC = createRPC(schema, '', 'UserRPC', import('../lib/fetcher'), { validateOnClient: import('../lib/validate-on-client'), apiRoot: 'http://localhost:3000/api', });

You can also set fetcher and validateOnClient per segment, OpenAPI mixins included. This way each segment can have its own options, auth and validation library.

/** @type {import('vovk').VovkConfig} */ const config = { outputConfig: { imports: { // for all segments fetcher: './src/lib/fetcher', validateOnClient: './src/lib/validate-on-client', }, segments: { admin: { imports: { // for the "admin" segment only fetcher: './src/lib/admin-fetcher', validateOnClient: './src/lib/admin-validate-on-client', }, }, }, }, }; export default config;

fetcher

The fetcher validates the input on the client, sends the HTTP request, and returns the data by content type:

  • For application/json and any +json type, such as application/problem+json, it returns the parsed JSON. A response without a body, such as a 204 or the answer to a HEAD request, gives null. An error status throws an HttpException with the body’s message, or with the detail or title of a problem details document.
  • For JSON Lines (application/jsonl, application/jsonlines or application/x-ndjson), it returns a disposable async iterable.
  • For other content types, it returns the Response object as is, so you can read text or binary data. A status of 400 or above throws an HttpException instead, with the response text as its message.

The comparison ignores the content type’s parameters and letter case, so Application/JSON; charset=utf-8 is JSON.

The fetcher can also take custom options, passed to an RPC method on each call.

import { UserRPC } from '@/client'; const user = await UserRPC.updateUser({ // ... successMessage: 'Successfully updated the user', someOtherCustomFlag: true, });

createFetcher

The module at imports.fetcher must export a fetcher variable. createFetcher from vovk/fetcher makes one.

./src/lib/fetcher.ts
import { createFetcher } from 'vovk/fetcher'; export const fetcher = createFetcher<{ successMessage?: string; // "Successfully created a new user" useAuth?: boolean; // true sets the Authorization header someOtherCustomFlag?: boolean; // any other option for the RPC method }>({ prepareRequestInit: async (init, { useAuth, someOtherCustomFlag }) => { // ... return { ...init, headers: { ...init.headers, ...(useAuth ? { Authorization: 'Bearer token' } : {}), }, }; }, transformResponse: async (data, { someOtherCustomFlag }) => { // ... return data; }, onSuccess: async (data, { successMessage }) => { if (successMessage) { alert(successMessage); } }, onError: async (error) => { alert(error.message); }, });

Every RPC method then accepts these options:

import { UserRPC } from '@/client'; await UserRPC.updateUser({ params: { param: 'value' }, query: { id: 'value' }, body: { email: 'value' }, successMessage: 'Successfully updated the user', useAuth: true, someOtherCustomFlag: true, });

createFetcher takes an object with:

prepareRequestInit(init: RequestInit, options: T)

Runs before the request. It receives the prepared init and the call’s custom options, and must return a RequestInit object, usually based on init. Use it for auth headers, the Next.js next options, logging, or other work before the request.

transformResponse(data: unknown, options: T, info: { response: Response, init: RequestInit, schema })

Changes the response data before the caller gets it. data is JSON, a disposable  async iterator , or a Response, depending on the content type. Return the new value. The info argument holds the original Response, the RequestInit used, and the method’s schema: the same object as UserRPC.updateUser.schema, with fields such as schema.operationObject.

onError(error: HttpException, options: T)

Runs when a request fails. Use it for error messages, logging, or custom handling. Its third argument, info, holds the response, init, respData and schema; the first three are null when the call failed before it had them.

onSuccess(data: unknown, options: T)

Runs when a request succeeds. Use it for success messages, logging, or post-processing. Its third argument, info, is the same as in transformResponse.

Event-style callbacks

You can also add onSuccess and onError callbacks after you create the fetcher. Use this when a callback needs something that doesn’t exist at creation time, such as a React context value or a Zustand store. Both methods return a function that removes the callback. Several callbacks run in the order you add them.

const unsubSuccess = fetcher.onSuccess((data, { successMessage }) => { console.log('Success:', successMessage); }); const unsubError = fetcher.onError((error) => { console.error('Error:', error.message); }); // Later, remove the callbacks: unsubSuccess(); unsubError();

validateOnClient

validateOnClient validates the input of an RPC or API method (params, query, body) on the client. Create it with createValidateOnClient from vovk/create-validate-on-client. It takes a validate function, which receives the input data, its JSON schema and metadata, and returns the validated data or throws. Validation runs only when both the input and its schema exist.

./src/lib/validate-on-client.ts
import { validateData } from 'some-json-validation-library'; import { createValidateOnClient, HttpException, HttpStatus } from 'vovk/create-validate-on-client'; export const validateOnClient = createValidateOnClient({ validate: async (input, schema, meta) => { const isValid = validateData(input, schema); if (!isValid) { throw new HttpException(HttpStatus.NULL, 'Validation failed', { // ... optional cause }); } return input; }, });

vovk-ajv

vovk-ajv  is the main client-side validation library, built on Ajv . npx vovk-cli init installs and configures it. You can set Ajv options and the target JSON Schema draft under config.libs.ajv in vovk.config.

  • Ajv runs with strict: false unless your options say otherwise, so it ignores keywords JSON Schema doesn’t define, such as Zod’s example or OpenAPI’s discriminator and x-*.
  • A pattern is read with the u flag, which \p{…} needs, as in z.emoji(). A pattern the u flag refuses, such as one with a \- escape that Zod’s regexes allow, is read without it.
  • OpenAPI 3.0’s boolean exclusiveMinimum and exclusiveMaximum are read as the bounds they mark.
  • A FormData or URLSearchParams body holds strings, and so do params and query, which the URL carries. So they are validated with coerceTypes: true: "5" passes as a number. Ajv never edits the params and query it checks, so they are sent as given.
  • When Ajv can’t compile a schema, the client skips validation for it and logs a warning. Where Ajv can’t generate code at all, as on a page whose Content Security Policy has no 'unsafe-eval', the client skips every check and warns once. The server still validates the request.
npm install vovk-ajv
vovk.config.mjs
/** @type {import('vovk').VovkConfig} */ const config = { outputConfig: { imports: { validateOnClient: 'vovk-ajv', }, }, libs: { /** @type {import('vovk-ajv').VovkAjvConfig} */ ajv: { options: { // Ajv options strict: false, }, target: 'draft-2020-12', // detected from $schema unless you set it }, }, }; export default config;
Last updated on