Why amqp-contract?
Working with RabbitMQ and AMQP messaging is powerful and flexible, but it comes with significant challenges when building TypeScript applications. amqp-contract solves these problems by bringing a contract-first, type-safe approach to AMQP messaging.
The Problem
Traditional AMQP development in TypeScript lacks type safety and validation, leading to several issues:
1. No Type Safety
Without types, you're working blind:
// ❌ Traditional approach - no type safety
channel.publish(
"orders",
"order.created",
Buffer.from(
JSON.stringify({
orderId: "ORD-123", // What fields are required? What types?
}),
),
);
channel.consume("order-processing", (msg) => {
const data = JSON.parse(msg.content.toString()); // unknown type
console.log(data.orderId); // No autocomplete, no type checking
// Is it orderId or order_id? Did the field change?
});2. Manual Validation Everywhere
You must validate messages manually at every boundary:
// ❌ Validation scattered throughout the codebase
function validateOrder(data: unknown): Order {
if (!data || typeof data !== "object") {
throw new Error("Invalid data");
}
const order = data as any;
if (typeof order.orderId !== "string") {
throw new Error("orderId must be a string");
}
// ... dozens more checks ...
return order as Order;
}3. Runtime Errors from Wrong Data
Without validation, invalid messages cause runtime failures:
// ❌ No validation - crashes at runtime
channel.consume("orders", (msg) => {
const order = JSON.parse(msg.content.toString());
processPayment(order.amount); // TypeError: amount is undefined
});4. Scattered Message Definitions
Message schemas are duplicated across services:
// ❌ Duplicated schemas in multiple files
// publisher.ts
interface OrderEvent {
orderId: string;
amount: number;
}
// consumer.ts
interface OrderEvent {
// Same schema, different file
orderId: string;
amount: number;
}5. Difficult Refactoring
Changing a message schema means hunting through multiple files:
// ❌ Change orderId to id - must update everywhere manually
// No compile-time checks to catch all usagesThe Solution
amqp-contract transforms AMQP development with a contract-first approach:
1. End-to-End Type Safety
Define your contract once, get types everywhere:
import {
defineContract,
defineExchange,
defineQueue,
definePublisherFirst,
defineMessage,
} from "@amqp-contract/contract";
import { TypedAmqpClient } from "@amqp-contract/client";
import { TypedAmqpWorker } from "@amqp-contract/worker";
import { z } from "zod";
// 1. Define resources
const ordersExchange = defineExchange("orders", "topic", { durable: true });
const orderProcessingQueue = defineQueue("order-processing", { durable: true });
// 2. Define message schema
const orderMessage = defineMessage(
z.object({
orderId: z.string(),
customerId: z.string(),
amount: z.number().positive(),
}),
);
// 3. Publisher-first pattern for event-oriented messaging
const { publisher: orderCreatedPublisher, createConsumer: createOrderCreatedConsumer } =
definePublisherFirst(ordersExchange, orderMessage, { routingKey: "order.created" });
// 4. Create consumer from event (ensures schema consistency)
const { consumer: processOrderConsumer, binding: orderBinding } =
createOrderCreatedConsumer(orderProcessingQueue);
// 5. Create contract
const contract = defineContract({
exchanges: { orders: ordersExchange },
queues: { orderProcessing: orderProcessingQueue },
bindings: { orderBinding },
publishers: {
orderCreated: orderCreatedPublisher,
},
consumers: {
processOrder: processOrderConsumer,
},
});
// 4. Publisher gets full type safety
const clientResult = await TypedAmqpClient.create({
contract,
urls: ["amqp://localhost"],
});
const client = clientResult.get();
const result = await client.publish("orderCreated", {
orderId: "ORD-123", // ✅ TypeScript knows these fields!
customerId: "CUST-456", // ✅ Autocomplete works!
amount: 99.99, // ✅ Type checked at compile time!
});
// 5. Consumer gets fully typed messages
const workerResult = await TypedAmqpWorker.create({
contract,
handlers: {
processOrder: async (message) => {
console.log(message.orderId); // ✅ Fully typed!
console.log(message.customerId); // ✅ Autocomplete!
console.log(message.amount); // ✅ Type safe!
},
},
urls: ["amqp://localhost"],
});2. Automatic Validation
Schema validation happens automatically at network boundaries:
// ✅ Validation happens automatically
const result = await client.publish("orderCreated", {
orderId: "ORD-123",
customerId: "CUST-456",
amount: -10, // ❌ Validation error: amount must be positive
});
result.match({
Ok: () => console.log("Published"),
Error: (error) => console.error("Validation failed:", error),
});3. Compile-Time Checks
TypeScript catches errors before runtime:
// ❌ TypeScript error at compile time
await client.publish("orderCreated", {
orderId: "ORD-123",
// Missing customerId and amount - TypeScript error!
});
// ❌ TypeScript error for wrong types
await client.publish("orderCreated", {
orderId: 123, // Error: orderId must be string
customerId: "CUST-456",
amount: 99.99,
});4. Single Source of Truth
Your contract is the single source of truth:
// ✅ One contract definition using publisher-first pattern
const { publisher: orderCreatedPublisher, createConsumer: createOrderCreatedConsumer } =
definePublisherFirst(ordersExchange, orderMessage, { routingKey: "order.created" });
const { consumer: processOrderConsumer, binding: orderBinding } =
createOrderCreatedConsumer(orderProcessingQueue);
const contract = defineContract({
// Define once, use across all publishers and consumers
// Publisher and consumer guaranteed to use same schema
exchanges: { orders: ordersExchange },
queues: { orderProcessing: orderProcessingQueue },
bindings: { orderBinding },
publishers: {
orderCreated: orderCreatedPublisher,
},
consumers: {
processOrder: processOrderConsumer,
},
});5. Safe Refactoring
Refactoring is safe and guided by TypeScript:
// Change the schema
const orderMessage = defineMessage(
z.object({
id: z.string(), // Changed from orderId to id
customerId: z.string(),
amount: z.number().positive(),
}),
);
// TypeScript immediately shows all places that need updates:
// - Publisher calls
// - Consumer handlers
// - Type definitionsKey Benefits
Better Developer Experience
- Autocomplete - Your IDE knows all message fields and types
- Inline Documentation - Hover over fields to see schemas
- Refactoring Support - Rename fields safely across the codebase
- Jump to Definition - Navigate from usage to contract definition
Compile-Time Safety
- Catch Errors Early - TypeScript catches issues before runtime
- Type Inference - No manual type annotations needed
- Exhaustive Checks - Ensure all consumers are implemented
Runtime Safety
- Automatic Validation - Zod, Valibot, or ArkType validate messages
- Explicit Error Handling - Result types for predictable error handling
- No Surprises - Invalid messages are caught at boundaries
Maintainability
- Single Source of Truth - Contract defines everything
- Documentation - AsyncAPI generation for API docs
- Version Control - Track contract changes in git
- Clear Boundaries - Well-defined publisher/consumer interfaces
Inspired By
This project adapts the contract-first approach from:
- tRPC - End-to-end type safety for RPC
- oRPC - Contract-first RPC with OpenAPI
- ts-rest - Type-safe REST APIs
We've brought their excellent ideas to the world of RabbitMQ and AMQP messaging.
When to Use amqp-contract
Perfect for:
- ✅ TypeScript projects using RabbitMQ/AMQP
- ✅ Microservices with message-based communication
- ✅ Projects requiring strong type safety
- ✅ Teams that value developer experience
- ✅ Applications with complex message schemas
Consider alternatives if:
- ❌ You're not using TypeScript
- ❌ You need extremely low overhead (though validation overhead is minimal)
- ❌ You prefer dynamic, untyped messaging
Next Steps
Ready to get started?
- Getting Started → - Install and create your first contract
- Core Concepts → - Understand the fundamentals
- Examples → - See real-world usage patterns