Skip to Content
TypeScript Client

TypeScript RPC Client

The client uses only fetch and web streams, so it runs in Node.js, Bun, Deno, browsers and Cloudflare Workers.

A controller, with its procedures as static methods, compiles to an RPC module. The module has the same structure, but its methods take different arguments. For example, take this controller:

src/modules/user/user-controller.ts
import { z } from 'zod'; import { procedure, prefix, post, operation } from 'vovk'; @prefix('users') export default class UserController { @operation({ summary: 'Update user (Zod)', description: 'Update user by ID with Zod validation', }) @post('{id}') static updateUser = procedure({ body: z .object({ name: z.string().meta({ description: 'User full name' }), age: z.number().min(0).max(120).meta({ description: 'User age' }), email: z.email().meta({ description: 'User email' }), }) .meta({ description: 'User object' }), params: z.object({ id: z.uuid().meta({ description: 'User ID' }), }), query: z.object({ notify: z.enum(['email', 'push', 'none']).meta({ description: 'Notification type' }), }), output: z .object({ success: z.boolean().meta({ description: 'Success status' }), }) .meta({ description: 'Response object' }), }).handle(async (req, { id }) => { const { name, age } = await req.vovk.body(); const { notify } = req.vovk.query(); // do something with the data console.log(`Updating user ${id}:`, { name, age, notify }); return { success: true, }; }); }

It compiles to an RPC module with an updateUser method. The method takes body, params and query in one object:

import { UserRPC } from '@/client'; const updatedUser = await UserRPC.updateUser({ body: { name: 'John Doe', age: 30, email: 'john@example.com' }, params: { id: '123e4567-e89b-12d3-a456-426614174000' }, query: { notify: 'push' }, });

updateUser validates the input on the client, puts query and params into the URL, and sends a standard fetch request, which UserController.updateUser handles on the server. The RPC method returns a promise of the procedure’s return type. The result is the handler’s value after JSON, so a handler returns JSON data: a Date arrives as a string. .fn() returns the value as is. In plain fetch, the call looks like this:

const resp = await fetch(`/api/users/${id}?notify=push`, { method: 'POST', body: JSON.stringify({ /* ... */ }), }); const updatedUser = await resp.json();

An internal function, createRPC, creates the RPC module. It uses the default imports, or the imports set in the vovk.config file. See imports customization for details.

import type { VovkFetcher } from "vovk/fetcher"; import { createRPC } from "vovk/create-rpc"; import { schema } from "./schema"; import type { Controllers as Controllers0 } from "../app/api/[[...vovk]]/route.ts"; // The arguments are: schema, segmentName, rpcModuleName, fetcher and options export const UserRPC = createRPC< Controllers0["UserRPC"], typeof import("vovk/fetcher").fetcher extends VovkFetcher<infer U> ? U : never >(schema, "", "UserRPC", import("vovk/fetcher"), { validateOnClient: import("vovk-ajv"), });

The generated client imports vovk subpaths, such as vovk/fetcher, and JSON files with import attributes. It compiles with "moduleResolution" set to "bundler" (the Next.js default), "node16" or "nodenext". With "node", TypeScript can’t find vovk/fetcher. The client alone compiles on TypeScript 5.3+, but a service or a handler that takes its types from its own procedure, as in these docs and the files vovk new writes, needs TypeScript 5.5+.

Under "node16" or "nodenext", the client imports its own files by their .js names, such as ./schema.js (by their .ts names when allowImportingTsExtensions is on). Turbopack resolves a .js name to the .ts file, but next build --webpack doesn’t, unless next.config sets experimental: { extensionAlias: { '.js': ['.ts', '.tsx', '.js'] } }.

RPC Method Options

Besides body, params and query, every RPC method accepts the options below. You can extend the list with a custom fetcher.

apiRoot

Overrides the API root. The default is /api. You can also configure it with rootEntry, origin or both.

init

Passes RequestInit options (the fetch options), such as headers and credentials, and Next.js-specific options  such as next: { revalidate: number }.

const user = await UserRPC.updateUser({ body: { /* ... */ }, params: { /* ... */ }, query: { /* ... */ }, init: { headers: { 'X-Custom-Header': 'value', }, credentials: 'include', next: { revalidate: 60 }, }, });

transform

Post-processes the result. The function receives the parsed response data and the original Response, and returns a new value.

const user = await UserRPC.updateUser({ body: { /* ... */ }, params: { /* ... */ }, query: { /* ... */ }, transform: (data, response) => { // Modify the response here return value; }, });

It can also return the Response together with the data:

const [user, response] = await UserRPC.updateUser({ body: { /* ... */ }, params: { /* ... */ }, query: { /* ... */ }, transform: (data, response) => [data, response] as const, }); response satisfies Response;

disableClientValidation

Turns off client-side validation for this call. Use it when debugging, to see the server’s validation errors instead.

await UserRPC.updateUser({ // ... disableClientValidation: true, });

interpretAs

Sets the content type that the client uses to read the response. For example, use it when the server returns JSON Lines without setting content-type to application/jsonl.

const user = await UserRPC.updateUser({ body: { /* ... */ }, params: { /* ... */ }, query: { /* ... */ }, interpretAs: 'application/jsonl', });

validateOnClient

Overrides the validateOnClient setting from imports. It takes the function or a module that exports it, such as import('vovk-ajv').

fetcher

Overrides the fetcher for this call, for example one made with createFetcher.

withDefaults

The withDefaults method returns a new RPC module with default options for every call. The defaults are deep-merged with each call’s options. Headers merge by name, ignoring case, whether a layer gives them as an object, a Headers instance or entries. A header given in the call replaces a default one.

import { UserRPC } from '@/client'; const WithDefaultsUserRPC = UserRPC.withDefaults({ apiRoot: 'https://api.example.com/v1', init: { headers: { 'x-hello': 'world', }, }, }); const user = await WithDefaultsUserRPC.updateUser({ // ... });

Customization

You can change the client’s fetch function and its option types. See the fetcher customization docs.

await UserRPC.updateUser({ // ... successMessage: 'Successfully updated the user', someOtherCustomFlag: true, });

Type Override

If type inference can’t find the return type, pass it as a type argument. You don’t need to cast to unknown first.

import { UserRPC } from '@/client'; import type { SomeType } from '../types'; // ... // Override the return type const updatedUser = await UserRPC.updateUser<SomeType>(/* ... */);

Async Iterable

src/modules/user/user-controller.ts
import { get } from 'vovk'; export default class UserController { @get() static async *doSomething(/* ... */) { yield* iterable; } }

If the handler returns an async iterable, the RPC method resolves to a disposable  async iterator  that reads the JSON Lines stream.

import { StreamRPC } from '@/client'; using stream = await StreamRPC.getJSONLines(); for await (const { message } of stream) { console.log('Received message:', message); }

Access to Schema

Every RPC method has the emitted schema in these properties:

  • schema - the method’s schema;
  • controllerSchema - the schema of the method’s controller;
  • segmentSchema - the schema of the segment;
  • fullSchema - the full schema, of type VovkSchema, with all segments and the emitted config (by default, only the libs and rootEntry options; see config documentation).

For the type of one of them, use typeof, such as typeof UserRPC.updateUser.segmentSchema.

console.log(UserRPC.updateUser.schema.validation?.body); // the JSON schema of the body console.log(UserRPC.updateUser.schema.operationObject); // the OpenAPI operation object of this method console.log(UserRPC.updateUser.fullSchema.meta?.config.libs?.ajv); // a config option

LLM tools can use this schema to define their parameters.

getURL method

Every RPC method has a type-safe getURL function. It returns the method’s URL, with params and query in it.

import { UserRPC } from '@/client'; const url = UserRPC.updateUser.getURL({ params: { id: '69' }, query: { notify: 'push' }, apiRoot: 'https://api.example.com/v1', // optional }); console.log(url); // "https://api.example.com/v1/users/69?notify=push"

Use it to call fetch directly:

const response = await fetch( UserRPC.updateUser.getURL({ /* ... */ }), { method: 'POST', // ... other fetch options } );

React Query

Every RPC method has a queryKey function. It returns a globally unique key for @tanstack/react-query . The key is an array: [segmentName, controllerPrefix, rpcModuleName, decoratorPath, httpMethod, ...key]. ...key holds optional extra values that tell similar queries apart.

import { useQuery } from '@tanstack/react-query'; import { UserRPC } from '@/client'; const MyComponent = () => { const query = useQuery({ queryKey: UserRPC.getUser.queryKey(['123']), queryFn: () => UserRPC.getUser({ params: { id: '123' }, }), }); return <div>{query.isLoading ? 'Loading...' : JSON.stringify(query.data)}</div>; };

View live example on examples.vovk.dev » 

Use the key to invalidate the cache, to refetch, and with other React Query features.

queryClient.invalidateQueries({ queryKey: UserRPC.getUser.queryKey().slice(0, 3), // Invalidate all queries for the `UserRPC` module });

For streamed responses, streamedQuery reads JSON Lines as an array.

import { useQuery, experimental_streamedQuery as streamedQuery } from '@tanstack/react-query'; import { JSONLinesRPC } from '@/client'; const JSONLinesComponent = () => { const query = useQuery({ queryKey: JSONLinesRPC.streamTokens.queryKey(), queryFn: streamedQuery({ streamFn: () => JSONLinesRPC.streamTokens(), }), }); return ( <div> Stream result: {query.data?.map(({ message }, i) => <span key={i}>{message}</span>) ?? <em>Loading...</em>} </div> ); };

RPC methods also work as mutation functions.

import { useMutation } from '@tanstack/react-query'; import { UserRPC } from '@/client'; const MyComponent = () => { const mutation = useMutation({ mutationFn: UserRPC.updateUser, }); return ( <div> <button onClick={() => mutation.mutate({ body: { name: 'John Doe', age: 30, email: 'john@example.com' }, params: { id: '123e4567-e89b-12d3-a456-426614174000' }, query: { notify: 'push' }, }) } > Update User </button> {mutation.isPending ? 'Loading...' : JSON.stringify(mutation.data)} </div> ); };

openapi and schema

The generated client also exports the OpenAPI spec as openapi and the Vovk Schema as schema, each from a module of the same name.

import { openapi } from '@/client/openapi'; import { schema } from '@/client/schema';

The main module exports schema too.

import { schema } from '@/client';

The segmented client has the same exports, but openapi and schema hold only the data of their segment:

import { openapi } from '@/client/admin/openapi'; import { schema } from '@/client/admin'; // or from '@/client/admin/schema'

Used Templates

The TypeScript RPC client is generated from these templates:

For more, see the client templates documentation.

Last updated on