Skip to content

Message Schemas

Message schemas are the foundation of type-safe WebSocket communication in Bun WebSocket Router. They define the structure and validation rules for your messages.

Creating Message Schemas

Basic Messages

The simplest schema is a message without a payload:

typescript
import { messageSchema } from "bun-ws-router";

// Message with just a type
const PingMessage = messageSchema("PING");
const DisconnectMessage = messageSchema("DISCONNECT");

Messages with Payloads

Add validated payloads using Zod or Valibot:

typescript
import { messageSchema } from "bun-ws-router";
import { z } from "zod";

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

const ChatMessage = messageSchema(
  "CHAT_MESSAGE",
  z.object({
    text: z.string().max(1000),
    roomId: z.uuid(),
    mentions: z.array(z.string()).optional(),
  }),
);
typescript
import { messageSchema } from "bun-ws-router/valibot";
import * as v from "valibot";

const LoginMessage = messageSchema(
  "LOGIN",
  v.object({
    username: v.pipe(v.string(), v.minLength(3), v.maxLength(20)),
    password: v.pipe(v.string(), v.minLength(8)),
  }),
);

const ChatMessage = messageSchema(
  "CHAT_MESSAGE",
  v.object({
    text: v.pipe(v.string(), v.maxLength(1000)),
    roomId: v.pipe(v.string(), v.uuid()),
    mentions: v.optional(v.array(v.string())),
  }),
);

Schema Validation Features

String Validation

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

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

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

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

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

Number Validation

typescript
const GameMessage = messageSchema(
  "GAME_UPDATE",
  z.object({
    // Integer validation
    score: z.number().int().min(0),

    // Float with precision
    position: z.object({
      x: z.number().finite(),
      y: z.number().finite(),
    }),

    // Range validation
    health: z.number().min(0).max(100),
  }),
);

Complex Types

typescript
const OrderMessage = messageSchema(
  "CREATE_ORDER",
  z.object({
    // Nested objects
    customer: z.object({
      id: z.uuid(),
      name: z.string(),
      email: z.email(),
    }),

    // Arrays with validation
    items: z
      .array(
        z.object({
          productId: z.string(),
          quantity: z.number().int().positive(),
          price: z.number().positive(),
        }),
      )
      .min(1)
      .max(50),

    // Union types
    payment: z.union([
      z.object({ type: z.literal("card"), last4: z.string() }),
      z.object({ type: z.literal("paypal"), email: z.email() }),
    ]),

    // Optional with default
    notes: z.string().optional().default(""),
  }),
);

Custom Metadata

Add custom metadata to messages:

typescript
const AuthenticatedMessage = messageSchema(
  "AUTHENTICATED_ACTION",
  z.object({ action: z.string() }),
  {
    // Custom metadata schema
    meta: z.object({
      correlationId: z.uuid(),
      version: z.string(),
    }),
  },
);

Error Messages

Define error message schemas for consistent error handling:

typescript
import { ErrorCode } from "bun-ws-router";

const ErrorMessage = messageSchema(
  "ERROR",
  z.object({
    code: z.nativeEnum(ErrorCode),
    message: z.string(),
    details: z.record(z.unknown()).optional(),
  }),
);

// Use in handlers
ctx.send(ErrorMessage, {
  code: ErrorCode.VALIDATION_ERROR,
  message: "Invalid input",
  details: { field: "email", reason: "Invalid format" },
});

Type Inference

Schemas provide full TypeScript type inference:

typescript
const UserProfileMessage = messageSchema(
  "USER_PROFILE",
  z.object({
    id: z.uuid(),
    name: z.string(),
    age: z.number().int().positive(),
    tags: z.array(z.string()),
  }),
);

// Type is automatically inferred
type UserProfile = z.infer<typeof UserProfileMessage.schema>;
// {
//   id: string;
//   name: string;
//   age: number;
//   tags: string[];
// }

router.onMessage(UserProfileMessage, (ctx) => {
  // ctx.payload is fully typed as UserProfile
  console.log(ctx.payload.name); // string
  console.log(ctx.payload.age); // number
});

Validation Transforms

Transform data during validation:

typescript
const DateMessage = messageSchema(
  "SCHEDULE_EVENT",
  z.object({
    // Parse ISO string to Date
    startDate: z
      .string()
      .datetime()
      .transform((str) => new Date(str)),

    // Normalize strings
    title: z.string().transform((str) => str.trim().toLowerCase()),

    // Parse JSON
    metadata: z.string().transform((str) => JSON.parse(str)),
  }),
);

Reusable Schemas

Create reusable schema components:

typescript
// Common schemas
const UserIdSchema = z.uuid();
const TimestampSchema = z.number().int().positive();
const PaginationSchema = z.object({
  page: z.number().int().min(1).default(1),
  limit: z.number().int().min(1).max(100).default(20),
});

// Compose into message schemas
const GetUsersMessage = messageSchema(
  "GET_USERS",
  z.object({
    filters: z
      .object({
        role: z.enum(["user", "admin"]).optional(),
        active: z.boolean().optional(),
      })
      .optional(),
    pagination: PaginationSchema,
  }),
);

const UserEventMessage = messageSchema(
  "USER_EVENT",
  z.object({
    userId: UserIdSchema,
    timestamp: TimestampSchema,
    event: z.string(),
  }),
);

Performance Tips

  1. Cache Schemas: Define schemas once at module level
  2. Avoid Complex Transforms: Keep transforms simple for better performance
  3. Use Valibot: For 90% smaller bundles in production
  4. Validate Early: Let the router validate before your handler runs

Next Steps