Create type-safe JSON-RPC API clients and servers in JS/TS using any transport.
- JSON-RPC 2.0-compliant - Comprehensive spec compliance including batch requests, notifications, and errors
 - Supports many schema validation libraries - Uses 
@standard-schema/specto support Zod, Effect Schema, Valibot, ArkType, and more - Transport-agnostic - Works with HTTP, WebSockets, or any custom transport layer
 - Automatic request handling - Batch requests, notifications, and error responses handled automatically
 - Raw client modes - Disable client-side validation for performance or when server validation is guaranteed
 - Type safety - Full TypeScript support with automatic inference for requests and responses, including batch requests
 
zod-jsonrpc uses @standard-schema/spec for schema validation, supporting any library that implements the Standard Schema specification:
- Zod
 - Effect Schema
 - Valibot
 - ArkType
 - ...and more
 
bun add @danscan/zod-jsonrpc
yarn add @danscan/zod-jsonrpc
npm add @danscan/zod-jsonrpczod-jsonrpc makes it easy to create type-safe JSON-RPC servers and clients.
import { createServer, createClient, method } from '@danscan/zod-jsonrpc';
import { z } from 'zod';
// Define methods
const greet = method({
  paramsSchema: z.object({ name: z.string() }),
  resultSchema: z.string(),
}, ({ name }) => `Hello, ${name}!`);
// Create server and client
const server = createServer({ greet });
const client = createClient({ greet }, async (request) => {
  // Your transport layer here: fetch, WebSocket, etc.
  return server.request(request);
});
// Make type-safe calls
const greeting = await client.greet({ name: 'World' });
console.log(greeting); // "Hello, World!"Methods are the core building blocks of your JSON-RPC API. They define the input and output schemas for a given method, and the handler function that will be called when the method is invoked.
const greet = method({
  paramsSchema: z.object({ name: z.string() }),
  resultSchema: z.string(),
}, ({ name }) => `Hello, ${name}!`);You can also define methods with a paramsSchema and a resultSchema only, and provide a handler function later:
// Import this in your client and server
export const greet = method({
  paramsSchema: z.object({ name: z.string() }),
  resultSchema: z.string(),
});
// In your server implementation:
import { greet } from './methods';
const server = createServer({
  greet: greet.implement(({ name }) => `Hello, ${name}!`),
});Start by creating a server with your methods:
import { createServer, method, JSONRPCError } from '@danscan/zod-jsonrpc';
import { z } from 'zod';
const server = createServer({
  add: method({
    paramsSchema: z.object({ a: z.number(), b: z.number() }),
    resultSchema: z.number(),
  }, ({ a, b }) => a + b),
  divide: method({
    paramsSchema: z.object({ dividend: z.number(), divisor: z.number() }),
    resultSchema: z.number(),
  }, ({ dividend, divisor }) => {
    if (divisor === 0) {
      throw new JSONRPCError.InvalidParams({ message: 'Cannot divide by zero' });
    }
    return dividend / divisor;
  }),
});The server automatically handles single requests, batch requests, and notifications:
// Single request
const result = await server.request({
  id: 1,
  method: 'add',
  params: { a: 5, b: 3 },
  jsonrpc: '2.0',
});
// { id: 1, result: 8, jsonrpc: '2.0' }
// Batch request
const results = await server.request([
  { id: 1, method: 'add', params: { a: 5, b: 3 }, jsonrpc: '2.0' },
  { id: 2, method: 'divide', params: { dividend: 10, divisor: 2 }, jsonrpc: '2.0' },
]);
// [{ id: 1, result: 8, jsonrpc: '2.0' }, { id: 2, result: 5, jsonrpc: '2.0' }]Servers automatically convert any thrown error into proper JSON-RPC responses:
const server = createServer({
  validateAge: method({
    paramsSchema: z.object({ age: z.number() }),
    resultSchema: z.boolean(),
  }, ({ age }) => {
    if (age < 0) {
      // You can easily construct and throw a JSONRPC-specific error (ParseError, InvalidRequest, MethodNotFound, InvalidParams, InternalError)
      throw JSONRPCError.InvalidParams({ message: 'Age cannot be negative', data: { age } });
    }
    if (age >= 150) {
      // Any other kind of thrown error becomes a JSONRPCError.InternalError
      throw new Error(`Please don't use Bryan Johnson's age`);
    }
    return true;
  }),
});Errors are handled individually in batch requests, so one failure doesn't affect others:
const results = await server.request([
  { id: 1, method: 'validateAge', params: { age: 25 }, jsonrpc: '2.0' },
  { id: 2, method: 'validateAge', params: { age: -5 }, jsonrpc: '2.0' },
  { id: 3, method: 'validateAge', params: { age: 200 }, jsonrpc: '2.0' },
]);
// [
//   { id: 1, result: true, jsonrpc: '2.0' },
//   { id: 2, error: { code: -32602, message: 'Invalid params: Age cannot be negative' }, jsonrpc: '2.0' },
//   { id: 3, error: { code: -32603, message: 'Internal error', data: { message: 'Please don\'t use Bryan Johnson\'s age' } }, jsonrpc: '2.0' }
// ]Build type-safe clients that validate requests and responses:
import { createClient, method } from '@danscan/zod-jsonrpc';
// Define your transport
const sendRequest = async (request) => {
  const response = await fetch('/api/jsonrpc', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(request),
  });
  return response.json();
};
// Create client with method definitions
const client = createClient({
  add: method({
    paramsSchema: z.object({ a: z.number(), b: z.number() }),
    resultSchema: z.number(),
  }),
}, sendRequest);
// Make calls with full type safety
const sum = await client.add({ a: 5, b: 3 }); // numberClients provide a convenient API for batch requests with named results:
const results = await client.batch((ctx) => ({
  // Name each call in the batch so you can easily access the results by name
  // All calls are executed in parallel, so you can use the results as they come in
  sum: ctx.add({ a: 5, b: 3 }),
  product: ctx.multiply({ a: 5, b: 3 }),
  quotient: ctx.divide({ dividend: 10, divisor: 2 }),
}));
// Handle results with type-safe error checking
if (results.sum.ok) {
  console.log('Sum:', results.sum.value); // number
} else {
  console.error('Sum failed:', results.sum.error.message);
}
// Process all results
Object.entries(results).forEach(([operation, result]) => {
  if (result.ok) {
    console.log(`${operation}:`, result.value);
  } else {
    console.error(`${operation} failed:`, result.error.message);
  }
});Schemas that change your data in any way should usually only be applied once. If you provide a transforming schema in a method on both your client and server, you should use a raw client to avoid double transformation.
// This method is provided to the client and server
const normalize = method({
  paramsSchema: z.string().transform(s => s.toUpperCase()),
  resultSchema: z.string().transform(s => `Result: ${s}`),
}, (input) => input);
const server = createServer({ normalize });
// If you create your client manually, you can use the `raw` method to create a raw client
// This way, the schema transformation is only applied on the server
const client = createClient({ normalize }, sendRequest);
const rawClient = client.raw();
// When generating a client from a server, it will be a raw client by default
const client = server.createClient(sendRequest);If you are only using zod-jsonrpc for the client, your schema transformations will only be applied once, so you don't need to use a raw client.
const client = createClient({ normalize }, sendRequest);
const result = await client.normalize('hello'); // "Result: HELLO"Client has methods that return a new client with different validation modes:
client.raw(): Skip all schema parsing on the client, delegating to the serverclient.rawParams(): Skip only parameter validation on the clientclient.rawResults(): Skip only result validation on the clientclient.validating(): Re-enable all validation on the client
const client = createClient({ add }, sendRequest);
// Skip validation of both params and results
const rawClient = client.raw();
const result = await rawClient.add({ a: 5, b: 3 }); // No client-side validation
// Skip only params validation (results still validated)
const rawParamsClient = client.rawParams();
// Skip only result validation (params still validated)
const rawResultsClient = client.rawResults();
// Re-enable params and results validation
const validatingClient = rawClient.validating();
// For example, to disable validation on one request:
client.raw().add({ a: 5, b: 3 });Integrate with any HTTP framework or transport:
const jsonRpcServer = createServer({ add, divide });
// Bun
Bun.serve({
  fetch: async (req) => {
    const request = await req.json();
    const response = await jsonRpcServer.request(request);
    return Response.json(response);
  }
});
// Next.js App Router
export async function POST(request: Request) {
  const jsonRpcRequest = await request.json();
  const jsonRpcResponse = await jsonRpcServer.request(jsonRpcRequest);
  return Response.json(jsonRpcResponse);
}
// Express.js
app.post('/jsonrpc', async (req, res) => {
  const response = await jsonRpcServer.request(req.body);
  res.json(response);
});For larger applications, separate method definitions from implementations:
src/
  api/
    methods/          # Shared method definitions
      user.ts
      auth.ts
    server.ts         # Server implementation
    client.ts         # Client setup
Define methods separately to share between client and server:
// api/methods/user.ts
export const getUser = method({
  paramsSchema: z.object({ id: z.string().uuid() }),
  resultSchema: z.object({
    id: z.string().uuid(),
    name: z.string(),
    email: z.string().email(),
  }),
});
// api/server.ts
import * as methods from './methods';
export const server = createServer({
  getUser: methods.getUser.implement(async ({ id }) => {
    return await getUserFromDatabase(id);
  }),
});
// api/client.ts
import * as methods from './methods';
export const client = createClient({
  getUser: methods.getUser,
}, sendRequest);This structure enables you to share method definitions, maintain type safety across your entire API, and version your API independently of implementation details.