Skip to content

Message Schemas

Message schemas define the structure and validation for your WebSocket messages. WS-Kit provides a simple, type-safe API for creating and using schemas.

Export-with-Helpers Pattern

Use the export-with-helpers pattern to create schemas—no factories, no dual imports:

typescript
import { z, message } from "@ws-kit/zod";
// or
import { v, message } from "@ws-kit/valibot";

// Create a schema directly with the message() helper
const LoginMessage = message("LOGIN", {
  username: z.string().min(3).max(20),
  password: z.string().min(8),
});

// Use in routers and clients
const router = createRouter();
router.on(LoginMessage, (ctx) => {
  // ✅ ctx.payload.username is typed as string
  // ✅ ctx.payload.password is typed as string
});

Why this pattern:

  • Single import source — Import validator and helpers from one place to prevent dual-package hazards
  • No factoriesmessage() is a simple helper, not a factory-returned function
  • Full type inference — Constrained generics preserve types through handlers
  • Zero setup friction — Call message() directly, no factory call needed

Creating Message Schemas

Messages Without Payload

The simplest message type carries only metadata:

typescript
const PingMessage = message("PING");
const DisconnectMessage = message("DISCONNECT");

// Usage
router.on(PingMessage, (ctx) => {
  // ctx.type === "PING"
  // No payload available
  console.log("Received ping");
});

Messages With Payload

Add validated payloads using your validator:

Zod:

typescript
import { z, message } from "@ws-kit/zod";

const LoginMessage = message("LOGIN", {
  username: z.string().min(3).max(20),
  password: z.string().min(8),
});

const ChatMessage = message("CHAT", {
  roomId: z.string().uuid(),
  text: z.string().max(1000),
  mentions: z.array(z.string()).optional(),
});

Valibot:

typescript
import { v, message } from "@ws-kit/valibot";

const LoginMessage = message("LOGIN", {
  username: v.string(),
  password: v.string(),
});

const ChatMessage = message("CHAT", {
  roomId: v.string(),
  text: v.string(),
  mentions: v.optional(v.array(v.string())),
});

Validation Errors & Recovery

The router validates message schemas when you register them with router.on() or router.rpc(). This validation happens at startup (before your server starts), so you catch schema mistakes immediately.

RPC Must Have Response

An RPC schema must declare what it returns — the client needs to know when the request is complete.

typescript
// ❌ WRONG: RPC without response
const GetUser = message("GET_USER", { id: z.string() });
router.rpc(GetUser, (ctx) => {});
// Error: RPC schema for type "GET_USER" must have a response descriptor.

// ✅ CORRECT: RPC with response
const GetUser = message("GET_USER", {
  payload: { id: z.string() },
  response: { name: z.string() },
});
router.rpc(GetUser, (ctx) => {
  ctx.reply({ name: "Alice" });
});

Events Must Not Have Response

An event is fire-and-forget — the sender doesn't wait for a reply.

typescript
// ❌ WRONG: Event with response (contradiction)
const ChatMsg = message("CHAT", {
  text: z.string(),
  response: { status: z.string() },
});
router.on(ChatMsg, (ctx) => {});
// Error: Event schema for type "CHAT" must not have a response descriptor.

// ✅ CORRECT: Event without response
const ChatMsg = message("CHAT", { text: z.string() });
router.on(ChatMsg, (ctx) => {
  // Fire-and-forget — no reply needed
});

Type Must Be Non-Empty String

Every schema needs a non-empty type field for routing.

typescript
// ❌ WRONG: Empty type
const BadMsg = message("", { data: z.string() });
router.on(BadMsg, handler);
// Error: Invalid schema for type "": type must not be empty

// ✅ CORRECT: Clear, non-empty type
const GoodMsg = message("DATA_UPDATE", { data: z.string() });
router.on(GoodMsg, handler);

When you'll see these errors: At application startup, when route registration runs. Fix the schema definition and restart — no runtime protocol changes needed.

For the complete MessageDescriptor contract, see docs/specs/schema.md#messagedescriptor-validation-contract.

Schema Validation Features

String Validation

typescript
const UserMessage = message("USER_UPDATE", {
  // Basic string constraints
  username: z.string().min(3).max(20),

  // Email validation
  email: z.string().email(),

  // URL validation
  website: z.string().url().optional(),

  // Regex patterns
  phoneNumber: z.string().regex(/^\+?[1-9]\d{1,14}$/),

  // Enum values
  role: z.enum(["user", "admin", "moderator"]),

  // UUID validation
  id: z.string().uuid(),
});

Numbers and Dates

typescript
const DataMessage = message("DATA", {
  // Number validation
  count: z.number().int().positive(),
  price: z.number().multipleOf(0.01),

  // Date handling
  timestamp: z.number(), // Unix timestamp
  createdAt: z.date().optional(),
});

Complex Types

typescript
const ComplexMessage = message("COMPLEX", {
  // Arrays
  items: z.array(z.string()),
  scores: z.array(z.number()).nonempty(),

  // Objects
  metadata: z.object({
    source: z.string(),
    version: z.number(),
  }),

  // Unions
  value: z.union([z.string(), z.number()]),

  // Records
  tags: z.record(z.string()),
});

Using Schemas in Routers

Type-Safe Handlers

Handlers automatically receive typed payloads:

typescript
const JoinRoom = message("JOIN_ROOM", {
  roomId: z.string().uuid(),
  username: z.string(),
});

router.on(JoinRoom, (ctx) => {
  // ✅ ctx.payload.roomId is string
  // ✅ ctx.payload.username is string
  // ✅ ctx.type is "JOIN_ROOM" (literal)
  console.log(`${ctx.payload.username} joined ${ctx.payload.roomId}`);
});

Type Inference

All types are inferred from the schema:

typescript
const UserUpdate = message("USER_UPDATE", {
  id: z.number(),
  name: z.string(),
  role: z.enum(["user", "admin"]),
});

router.on(UserUpdate, (ctx) => {
  // All fields are fully typed
  const { id, name, role } = ctx.payload;
  console.log(`User ${id}: ${name} (${role})`);
});

Client-Side Validation

Schemas work client-side too—validate before sending:

typescript
import { z, message } from "@ws-kit/zod";

const LoginMessage = message("LOGIN", {
  username: z.string(),
  password: z.string(),
});

// Validate with safeParse before sending
const data = {
  type: "LOGIN",
  payload: { username: "alice", password: "secret" },
  meta: {},
};
const result = LoginMessage.safeParse(data);

if (result.success) {
  ws.send(JSON.stringify(result.data));
} else {
  console.error("Validation failed:", result.error);
}

// Or use the WS-Kit client which handles validation automatically
import { wsClient } from "@ws-kit/client/zod";

const client = wsClient({ url: "ws://localhost:3000" });
client.send(LoginMessage, { username: "alice", password: "secret" });

Request-Response Pattern (RPC)

Use the rpc() helper to bind request and response schemas together:

typescript
import { z, rpc } from "@ws-kit/zod";

// Bind request and response schemas together
const Ping = rpc("PING", { text: z.string() }, "PONG", { reply: z.string() });

const GetUser = rpc("GET_USER", { id: z.string() }, "USER_DATA", {
  user: z.object({ name: z.string(), email: z.string() }),
});

// Server side: use with router.rpc()
router.rpc(Ping, (ctx) => {
  ctx.reply(Ping.response, { reply: `Got: ${ctx.payload.text}` });
});

router.rpc(GetUser, async (ctx) => {
  const user = await db.users.findById(ctx.payload.id);
  ctx.reply(GetUser.response, { user });
});

// Client side: response schema auto-detected
const client = wsClient({ url: "ws://localhost:3000" });
const response = await client.request(Ping, { text: "hello" });
// response.type === "PONG"
// response.payload.reply === "Got: hello"

The RPC pattern provides these benefits:

  • No schema repetition at call sites
  • Response type automatically inferred from bound schema
  • Works seamlessly with router handlers
  • Type-safe request and response payloads

For more details, see docs/specs/schema.md.

Exporting Schemas

Define schemas in a shared file and reuse server + client:

typescript
// shared/messages.ts
import { z, message, rpc } from "@ws-kit/zod";

export const LoginMessage = message("LOGIN", {
  username: z.string(),
  password: z.string(),
});

export const LoginSuccess = message("LOGIN_SUCCESS", {
  userId: z.string(),
});

export const ChatMessage = message("CHAT", {
  text: z.string(),
});

// RPC schemas
export const GetUser = rpc("GET_USER", { id: z.string() }, "USER_DATA", {
  user: z.object({ name: z.string(), email: z.string() }),
});

Server:

typescript
import { createRouter } from "@ws-kit/zod";
import {
  LoginMessage,
  LoginSuccess,
  ChatMessage,
  GetUser,
} from "./shared/messages";

const router = createRouter();

router.on(LoginMessage, (ctx) => {
  ctx.send(LoginSuccess, { userId: "123" });
});

router.on(ChatMessage, (ctx) => {
  console.log(ctx.payload.text);
});

router.rpc(GetUser, (ctx) => {
  const user = { name: "Alice", email: "alice@example.com" };
  ctx.reply(GetUser.response, { user });
});

Client:

typescript
import { wsClient } from "@ws-kit/client/zod";
import {
  LoginMessage,
  LoginSuccess,
  ChatMessage,
  GetUser,
} from "./shared/messages";

const client = wsClient({ url: "wss://api.example.com" });

client.on(LoginSuccess, (msg) => {
  console.log(`Logged in as ${msg.payload.userId}`);
});

client.send(LoginMessage, { username: "alice", password: "secret" });

// RPC call
const response = await client.request(GetUser, { id: "123" });
console.log(`User: ${response.payload.user.name}`);

Discriminated Unions

Create unions of schemas for flexible message handling:

typescript
const PingMsg = message("PING");
const PongMsg = message("PONG", { latency: z.number() });
const ChatMsg = message("CHAT", { text: z.string() });

// Union type
const AnyMessage = z.discriminatedUnion("type", [PingMsg, PongMsg, ChatMsg]);

// Type narrowing works automatically
router.on(PingMsg, (ctx) => {
  console.log("Ping received");
});

router.on(ChatMsg, (ctx) => {
  console.log(`Chat: ${ctx.payload.text}`);
});

Standard Error Messages

All routers include a standard error message (see docs/specs/error-handling.md for details):

typescript
import { ErrorMessage, ErrorCode } from "@ws-kit/zod";
// or: import { ErrorMessage, ErrorCode } from "@ws-kit/valibot";

// Standard error codes (13 total, gRPC-aligned per ADR-015):
// Terminal: UNAUTHENTICATED, PERMISSION_DENIED, INVALID_ARGUMENT,
//          FAILED_PRECONDITION, NOT_FOUND, ALREADY_EXISTS, ABORTED
// Transient: DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED, UNAVAILABLE
// Server/evolution: UNIMPLEMENTED, INTERNAL, CANCELLED

// Usage with ctx.error() helper
router.on(SomeMessage, (ctx) => {
  if (!authorized) {
    ctx.error("PERMISSION_DENIED", "Not authorized");
  }
});

// ErrorMessage schema structure:
// {
//   type: "ERROR",
//   meta: { timestamp?, correlationId? },
//   payload: {
//     code: ErrorCode,          // One of the 13 standard codes
//     message?: string,         // Optional error description
//     details?: Record<string, any>,  // Optional additional context
//     retryable?: boolean       // Optional retry hint
//   }
// }

// Example error validation
const result = ErrorMessage.safeParse({
  type: "ERROR",
  meta: {},
  payload: {
    code: "INVALID_ARGUMENT",
    message: "Missing required field",
    details: { field: "username" },
  },
});

Note: ERROR messages are server-to-client only. Clients should NOT send ERROR type messages.

Import Warnings

⚠️ Always use the canonical import source:

typescript
// ✅ CORRECT
import { z, message } from "@ws-kit/zod";
const LoginMsg = message("LOGIN", { username: z.string() });

// ❌ AVOID (creates dual-package hazard)
import { z } from "zod";
import { message } from "@ws-kit/zod";
// Now z and message use different Zod instances!

Why this matters: Discriminated unions depend on all schemas using the same validator instance. Mixed imports cause silent type failures.

See ADR-007 for more details on the export-with-helpers pattern.