Skip to content
cable
Esc
↑↓navigate↵open⌘Jpreview
On this page

Define a contract

Declare global procedures and channel families with Standard Schema validators in one shared API.

@cablejs/contract defines the shared API without importing server code. It accepts Standard Schema v1 validators, including Zod, Valibot, ArkType, and Effect Schema.

Validator compatibility

The repository runs a small compatibility fixture against the pinned versions below. Each validator is exercised for procedure input transforms, channel parameters, server events, client event input, and inferred input/output types.

Validator Tested version Async transform coverage
Zod 4.5.4 Yes
Valibot 1.4.2 Yes
ArkType 2.2.3 Synchronous morphs
Effect Schema 4.0.0-rc.112 Synchronous schemas and transforms

validate() awaits Standard Schema results, so async schemas work when the validator returns a promise. Parsed values still cross cable’s JSON transport boundary. Keep procedure and event outputs JSON-compatible, and use the transport’s documented limits for dates, binary data, and other non-JSON values.

What a contract is

c.contract() returns a Contract<TTree>. The tree keeps your procedure and channel names at runtime and carries their schemas into TypeScript. Pass that same value to implement() on the server and createClient() in the client.

const api = c.contract({
  profile: c.query({
    input: z.object({}),
    output: z.object({ name: z.string() }),
  }),
});

type Api = typeof api;

Api is the contract tree, not a server router. The server still supplies every global procedure handler, and the client still chooses its transport endpoint. Schemas validate values at the transport boundary; inferred types keep the contract names and inputs aligned in application code.

import { c } from "@cablejs/contract";
import { z } from "zod";

const Message = z.object({
  from: z.string(),
  text: z.string(),
});

export const api = c.contract({
  posts: {
    list: c.query({
      input: z.object({ cursor: z.string().optional() }),
      output: z.object({ ids: z.array(z.string()) }),
      transport: { method: "GET", cache: "public, max-age=30" },
    }),
    create: c.mutation({
      input: z.object({ title: z.string().min(1) }),
      output: z.object({ id: z.string() }),
      errors: { FORBIDDEN: z.void() },
    }),
  },
  chat: c.channel("chat.{roomId}", {
    server: { message: Message },
    client: {
      send: {
        input: z.object({ text: z.string().max(2_000) }),
        errors: { MUTED: z.void() },
      },
    },
    procedures: {
      members: c.query({ input: z.object({}), output: z.number() }),
    },
    presence: z.object({ typing: z.boolean() }),
    history: { retain: "24h", max: 10_000 },
  }),
});

Global procedures

Use c.query for reads and c.mutation for writes. Each accepts input, output, and optional declared errors. A query may opt into GET transport with transport.method: "GET"; the cache value applies to successful responses.

Global procedures live anywhere in the contract tree outside a channel. Implement procedures covers their server handlers.

Channels

c.channel(pattern, definition) declares one parameterized channel family. The server object lists events delivered to clients. The client object lists client events and their optional declared errors. procedures declares calls scoped to one open channel. presence and history are optional channel capabilities.

Channel patterns cannot overlap. Contract keys cannot contain . or /, and channel event names cannot collide with channel-handle members such as on, dispose, presence, or history.

Inference

Inference helpers work on one contract node at a time:

import type { InferInput, InferServerEvent } from "@cablejs/contract";

type ListInput = InferInput<typeof api.posts.list>;
type MessageEvent = InferServerEvent<typeof api.chat, "message">;

Next, implement procedures against global leaves or read channel procedures for calls that belong to one channel.

Was this page helpful?