Skip to content

Getting Started

Get amqp-contract running in 5 minutes with a complete working example.

What is amqp-contract?

amqp-contract brings end-to-end type safety to AMQP/RabbitMQ messaging. Define your contract once, and get automatic validation, type inference, and compile-time checks throughout your application.

Prerequisites

  • Node.js 22.19+ - Download Node.js
  • RabbitMQ running locally - We'll use Docker (see below)
  • Basic TypeScript knowledge - Understanding of TypeScript syntax

Step 1: Start RabbitMQ

Use Docker to run RabbitMQ with the management plugin:

bash
docker run -d \
  --name rabbitmq \
  -p 5672:5672 \
  -p 15672:15672 \
  rabbitmq:4-management

Verify it's running:

TIP

If you already have RabbitMQ running locally, you can skip this step!

Manual Installation

Follow the official RabbitMQ installation guide.

Step 2: Install Packages

Create a new project and install dependencies:

bash
# Create project
mkdir amqp-demo && cd amqp-demo
npm init -y

# Install dependencies
pnpm add @amqp-contract/contract @amqp-contract/client @amqp-contract/worker unthrown zod
pnpm add -D typescript tsx

# Initialize TypeScript
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext
bash
# Create project
mkdir amqp-demo && cd amqp-demo
npm init -y

# Install dependencies
npm install @amqp-contract/contract @amqp-contract/client @amqp-contract/worker unthrown zod
npm install -D typescript tsx

# Initialize TypeScript
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext
bash
# Create project
mkdir amqp-demo && cd amqp-demo
npm init -y

# Install dependencies
yarn add @amqp-contract/contract @amqp-contract/client @amqp-contract/worker unthrown zod
yarn add -D typescript tsx

# Initialize TypeScript
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext

Why these packages?

unthrown appears in the public types (handlers return AsyncResult<void, HandlerError>), so you need it directly to construct handler results. zod can be any Standard Schema library — see Alternative Schema Libraries below. You do not need to install amqplib yourself; it ships as a dependency of the amqp-contract packages.

Optional Packages

Testing

For integration testing with RabbitMQ testcontainers:

bash
pnpm add -D @amqp-contract/testing

See the Testing Guide for more details.

AsyncAPI Generation

For generating AsyncAPI 3.0 specifications:

bash
pnpm add @amqp-contract/asyncapi

Alternative Schema Libraries

Instead of Zod, use Valibot or ArkType:

bash
# Valibot
pnpm add valibot

# ArkType
pnpm add arktype

Step 3: Create Contract

Create contract.ts - this defines your message schema and AMQP topology:

typescript
// contract.ts
import {
  defineContract,
  defineEventConsumer,
  defineEventPublisher,
  defineExchange,
  defineMessage,
  defineQueue,
} from "@amqp-contract/contract";
import { z } from "zod";

// 1. Define resources
const notificationsExchange = defineExchange("notifications", { type: "direct" });

const emailQueue = defineQueue("email-notifications");

// 2. Define message schema with Zod
const emailMessage = defineMessage(
  z.object({
    to: z.string().email(),
    subject: z.string(),
    body: z.string(),
  }),
  {
    summary: "Email notification message",
    description: "Sent when an email needs to be delivered",
  },
);

// 3. Define event publisher
const sendEmailEvent = defineEventPublisher(notificationsExchange, emailMessage, {
  routingKey: "email",
});

// 4. Compose contract - exchanges, queues, and bindings are auto-extracted
export const contract = defineContract({
  publishers: {
    sendEmail: sendEmailEvent,
  },
  consumers: {
    processEmail: defineEventConsumer(sendEmailEvent, emailQueue),
  },
});

Event or command?

This tutorial uses the event pattern — one publisher broadcasts, and any number of consumers subscribe. It's the right default for notifications and domain events like "an email was requested".

When the work has a single owner (a task queue — many producers, one consumer), reach for the command pattern instead. You define the consumer first with defineCommandConsumer, and defineCommandPublisher derives the publisher's message type from it so callers can't drift:

typescript
import { defineCommandConsumer, defineCommandPublisher } from "@amqp-contract/contract";

// The worker owns the queue and declares the one command it accepts
const processPayment = defineCommandConsumer(paymentQueue, paymentsExchange, paymentMessage, {
  routingKey: "payment.charge",
});

// Any service can send the command; its payload type comes from the consumer
const chargeCustomer = defineCommandPublisher(processPayment);
PatternShapeDefine with
Eventone publisher → many consumersdefineEventPublisherdefineEventConsumer
Commandmany publishers → one consumerdefineCommandConsumerdefineCommandPublisher

Both register the same way in defineContract, and both stay fully typed end to end. See Defining Contracts for the full comparison and the Command Pattern example for a complete walkthrough.

Step 4: Publisher

Create publisher.ts - publishes a message:

typescript
// publisher.ts
import { TypedAmqpClient } from "@amqp-contract/client";
import { contract } from "./contract.js";

async function main() {
  console.log("🚀 Starting publisher...");

  // Create client
  const client = await TypedAmqpClient.create({
    contract,
    urls: ["amqp://localhost"],
  }).getOrThrow();

  console.log("✅ Connected to RabbitMQ");

  // Publish message - fully typed!
  const result = await client.publish("sendEmail", {
    to: "user@example.com",
    subject: "Welcome to amqp-contract!",
    body: "This is a type-safe message from amqp-contract.",
  });

  result.match({
    ok: () => console.log("📧 Email message published!"),
    err: (error) => console.error("❌ Failed:", error.message),
    defect: (cause) => {
      throw cause;
    },
  });

  // Clean up
  await client.close();
  console.log("👋 Publisher closed");
}

main().catch(console.error);

Step 5: Consumer

Create consumer.ts - processes messages:

typescript
// consumer.ts
import { TypedAmqpWorker } from "@amqp-contract/worker";
import { OkAsync } from "unthrown";
import { contract } from "./contract.js";

async function main() {
  console.log("⚙️ Starting worker...");

  // Create worker with handlers
  const worker = await TypedAmqpWorker.create({
    contract,
    handlers: {
      processEmail: ({ payload }) => {
        // Payload is fully typed!
        console.log("\n📬 Received email:");
        console.log(`  To: ${payload.to}`);
        console.log(`  Subject: ${payload.subject}`);
        console.log(`  Body: ${payload.body}`);

        // Report success — handlers return an AsyncResult, they don't throw
        return OkAsync(undefined);
      },
    },
    urls: ["amqp://localhost"],
  }).getOrThrow();

  console.log("✅ Worker ready, waiting for messages...\n");
  console.log("Press Ctrl+C to stop\n");

  // Handle graceful shutdown
  process.on("SIGINT", async () => {
    console.log("\n👋 Shutting down...");
    await worker.close();
    process.exit(0);
  });
}

main().catch(console.error);

Doing real async work in a handler?

Wrap the promise with fromPromise(promise, qualify) (or fromSafePromise if it can never reject) instead of async/await — handlers return an AsyncResult, not a bare promise. See the Error Model guide and Worker Usage.

Step 6: Run It

Open two terminal windows:

Terminal 1 - Start the consumer:

bash
npx tsx consumer.ts

You should see:

⚙️ Starting worker...
✅ Worker ready, waiting for messages...

Press Ctrl+C to stop

Terminal 2 - Run the publisher:

bash
npx tsx publisher.ts

Expected Output

Publisher terminal:

🚀 Starting publisher...
✅ Connected to RabbitMQ
📧 Email message published!
👋 Publisher closed

Consumer terminal:

📬 Received email:
  To: user@example.com
  Subject: Welcome to amqp-contract!
  Body: This is a type-safe message from amqp-contract.

🎉 Success! You've just sent and received your first type-safe AMQP message!

What Just Happened?

  1. Contract Definition - You defined the message schema with Zod and AMQP topology
  2. Type Safety - TypeScript enforced the message structure at compile time
  3. Automatic Validation - Zod validated the message at runtime
  4. Publisher - The client published a message to RabbitMQ
  5. Consumer - The worker received and processed the message

Try This Next

Experiment with type safety:

In publisher.ts, try to publish an invalid message:

typescript
// ❌ This will cause a TypeScript error!
await client.publish("sendEmail", {
  to: "not-an-email", // Invalid email format
  subject: "Test",
  // Missing 'body' field
});

Notice:

  • TypeScript shows errors immediately
  • Your IDE provides autocomplete for message fields
  • You can't send invalid messages!

Key Benefits

  • Type Safety - Full TypeScript inference from contract to handlers
  • Auto Validation - Zod validates messages at publish and consume time
  • Compile Checks - TypeScript catches errors before runtime
  • Better DX - Autocomplete, refactoring, inline docs
  • Explicit Errors - Result types for predictable error handling

Common Issues

"Connection refused" or "ECONNREFUSED"

Cause: RabbitMQ is not running or not accessible

Solution:

bash
# Check if RabbitMQ container is running
docker ps | grep rabbitmq

# If not running, start it:
docker start rabbitmq

# Or create a new one:
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:4-management

"Cannot find module" errors

Cause: Missing dependencies or incorrect import extensions

Solution:

bash
# Reinstall dependencies
rm -rf node_modules package-lock.json
npm install

# Ensure you're using .js extensions in imports
# import { contract } from "./contract.js";  ✅
# import { contract } from "./contract";     ❌

TypeScript errors about module resolution

Cause: Incorrect TypeScript configuration

Solution: Update tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "strict": true
  }
}

Messages not being consumed

Cause: Consumer not running or binding mismatch

Solution:

  1. Ensure consumer is running in a separate terminal
  2. Check that routing keys match between publisher and binding
  3. Verify RabbitMQ management UI shows the queue has bindings

Next Steps

Now that you have amqp-contract working, explore more:

Need Help?

Released under the MIT License.