Schema Facade
Type-safe facade built on top of the Erebus pubsub client
Schema Facade
The Schema Facade is a helper class that adds strong typing and runtime validation to the Erebus PubSub client.
It ensures that every message you publish or subscribe to matches the shape defined in your schema.
How does it work?
The facade wraps your PubSub client and binds it to a SchemaMap
.
Each key in the schema represents a channel type (e.g. news
, chat
, presence
), and defines the payload shape for that channel.
At runtime, you can create many dynamic topics (e.g. sports
, finance
) under the same schema key.
All topics under the same schema key share the same payload contract.
Example
import { ErebusPubSubSchemas, ErebusClient, ErebusClientState } from "@erebus-sh/sdk";
import { z } from "zod";
// 1. Define your schema
const schemas = {
news: z.object({
title: z.string(),
body: z.string(),
timestamp: z.number(),
}),
} as const;
// 2. Create the client without the schema
const clientUnTyped = ErebusClient.createClient({
client: ErebusClientState.PubSub,
authBaseUrl: "http://localhost:4919", // your auth domain
wsBaseUrl: "ws://localhost:8787", // your ws domain (optional if you self-host locally)
});
// 3. Create the client with the schema
const client = new ErebusPubSubSchemas(
clientUnTyped,
schemas,
);
// 4. Use it with type safety
client.publish("news", "sports", {
title: "Team wins the finals",
body: "Fans celebrate in the streets",
timestamp: Date.now(),
});
await client.subscribe("news", "finance", (article) => {
console.log(`[Finance] ${article.payload.title}`);
});
Key points
Good to know
The Schema Facade automatically combines the schema key and the topic ID into a full channel name.
For example, using ("news", "sports")
becomes the topic string news_sports
behind the scenes.
- Schema key/Topic Schema → defines the payload shape (
news
,chat
, etc.) - Topic → dynamic runtime identifier (
sports
,finance
,room:123
) - Compile-time safety → TypeScript enforces correct payloads per schema key
- Runtime validation → Zod ensures payloads are valid before sending/after receiving
Why use it?
Without the facade, PubSub messages are just raw JSON strings. With the facade, you get:
- Safer publishes (payload checked at compile time + runtime)
- Safer subscriptions (payload automatically validated and typed)
- Cleaner, self-documenting API
- Typed history API (payloads validated when fetching history)
Typed History API
The Schema Facade also provides typed history methods:
getHistory()
Fetch historical messages with automatic type validation:
// Define schema
const schemas = {
news: z.object({
title: z.string(),
body: z.string(),
timestamp: z.number(),
}),
};
const client = new ErebusPubSubSchemas(baseClient, schemas);
// Fetch history with typing
const history = await client.getHistory("news", "sports", {
limit: 50,
direction: "backward"
});
// TypeScript knows the payload type!
history.items.forEach(msg => {
console.log(msg.payload.title); // ✅ string
console.log(msg.payload.body); // ✅ string
console.log(msg.payload.timestamp); // ✅ number
});
createHistoryIterator()
Create a typed paginator:
const getNext = client.createHistoryIterator("news", "finance", {
limit: 20,
direction: "backward"
});
const batch = await getNext();
if (batch) {
batch.items.forEach(msg => {
// Fully typed payload
console.log(`${msg.payload.title}: ${msg.payload.body}`);
});
}
Full Example with History
import { ErebusPubSubSchemas, ErebusClient, ErebusClientState } from "@erebus-sh/sdk";
import { z } from "zod";
// 1. Define schemas
const schemas = {
chat: z.object({
text: z.string(),
username: z.string(),
timestamp: z.number(),
}),
};
// 2. Create typed client
const baseClient = ErebusClient.createClient({
client: ErebusClientState.PubSub,
authBaseUrl: "http://localhost:4919",
});
const client = new ErebusPubSubSchemas(baseClient, schemas);
// 3. Join and connect
client.joinChannel("my-channel");
await client.connect();
// 4. Subscribe with typing
await client.subscribe("chat", "lobby", (msg) => {
// TypeScript infers the payload type
console.log(`${msg.payload.username}: ${msg.payload.text}`);
});
// 5. Publish with typing
client.publish("chat", "lobby", {
text: "Hello!",
username: "Alice",
timestamp: Date.now(),
});
// 6. Fetch history with typing
const history = await client.getHistory("chat", "lobby", {
limit: 50
});
console.log(`Loaded ${history.items.length} messages`);
history.items.forEach(msg => {
// Payload is automatically validated and typed
console.log(`[${msg.payload.username}] ${msg.payload.text}`);
});
Schema Validation
The facade validates payloads at two points:
1. On Publish (Before Sending)
// ✅ Valid - passes type check and runtime validation
client.publish("news", "sports", {
title: "Team Wins",
body: "Great game!",
timestamp: Date.now()
});
// ❌ Compile error - missing required field
client.publish("news", "sports", {
title: "Team Wins",
// body is missing
timestamp: Date.now()
});
// ❌ Runtime error - wrong type
client.publish("news", "sports", {
title: 123, // should be string
body: "Great game!",
timestamp: Date.now()
});
2. On Receive (Subscribe & History)
await client.subscribe("news", "sports", (msg) => {
// If validation fails, message is rejected
// Only valid messages reach this callback
console.log(msg.payload.title); // Guaranteed to be string
});
const history = await client.getHistory("news", "sports");
// All items have validated payloads
history.items.forEach(msg => {
console.log(msg.payload.title); // Guaranteed to be string
});
Advanced Patterns
Multiple Schemas
const schemas = {
chat: z.object({
text: z.string(),
username: z.string(),
}),
presence: z.object({
userId: z.string(),
status: z.enum(["online", "offline", "away"]),
}),
notification: z.object({
title: z.string(),
message: z.string(),
priority: z.enum(["low", "medium", "high"]),
}),
};
const client = new ErebusPubSubSchemas(baseClient, schemas);
// Each schema has its own type
client.publish("chat", "room-1", { text: "Hi", username: "Alice" });
client.publish("presence", "room-1", { userId: "123", status: "online" });
client.publish("notification", "alerts", {
title: "New Message",
message: "Alice says hi",
priority: "high"
});
Schema Evolution
// Version 1
const schemasV1 = {
message: z.object({
text: z.string(),
}),
};
// Version 2 - add optional fields for backwards compatibility
const schemasV2 = {
message: z.object({
text: z.string(),
username: z.string().optional(), // New field
timestamp: z.number().optional(), // New field
}),
};