DEV Community

Cover image for Using Moleculer with TypeScript: contracts, two service styles, and the honest caveats
Icebob
Icebob

Posted on

Using Moleculer with TypeScript: contracts, two service styles, and the honest caveats

"Does Moleculer work with TypeScript?" is the second most common question I get about it, right after the NestJS one. The short answer is yes — it ships its own type definitions, you can write services as typed schemas or as classes, and the runner loads .ts files. The longer answer has a few honest caveats, and since you'd hit them on day two anyway, I'd rather put them in the article.

This post shows a small typed system end to end: a shared contract file, two services in the two supported styles, a caller that gets autocomplete and compile errors for wrong action names, and the dev and production ways to run it. Everything below was compiled with strict: true and executed as shown, on Moleculer 0.15.2, Node.js 22 and TypeScript 7.

What Moleculer gives you out of the box

  • Bundled typings. moleculer ships index.d.ts; there is no @types/moleculer. The generics that matter: ServiceSchema<TSettings, TMethods, TVars> for typing this inside a service, Context<TParams, TMeta> for typing what a handler receives, and broker.call<TReturn, TParams>() for typing a call.
  • Class-based services. class X extends Service with this.parseServiceSchema({...}) in the constructor is a first-class way to define a service, not a hack.
  • .ts loading in the runner. moleculer-runner resolves moleculer.config.ts and *.service.ts files as long as a TypeScript loader is registered in the process (tsx, ts-node).
  • A TypeScript project template for the CLI (moleculer-climoleculer init project-typescript my-app), maintained alongside the framework.

What it does not give you — and I'll come back to each — is a decorator API in core, dependency injection, or any type information that crosses the network. That last one is not a Moleculer limitation, it is how distributed systems work, and the trick below is how you get most of the benefit anyway.

Step 1: a contract file

Action names are strings, and by default broker.call("products.get", { id: 1 }) returns Promise<any>. The fix is one file that says what each action takes and returns. Moleculer doesn't generate it; you write it, and both the service and every caller import it.

// src/contracts.ts — shared types for the actions in this system.
export interface Product { id: number; name: string; price: number; }
export interface Order { id: string; productId: number; quantity: number; total: number; }

/** Action name → { params, result }. */
export interface Actions {
  "products.get":  { params: { id: number };                       result: Product };
  "products.list": { params: { limit?: number };                   result: Product[] };
  "orders.create": { params: { productId: number; quantity: number }; result: Order };
}

/** Events and their payloads. */
export interface Events {
  "order.created": Order;
}

/** What every request carries in ctx.meta. */
export interface Meta { userId?: string; }
Enter fullscreen mode Exit fullscreen mode

Then a ~25-line wrapper turns broker.call and ctx.emit into typed functions:

// src/typed-broker.ts
import type { Context, ServiceBroker, CallingOptions } from "moleculer";
import type { Actions, Events, Meta } from "./contracts";

export type ActionName = keyof Actions;
export type ActionParams<N extends ActionName> = Actions[N]["params"];
export type ActionResult<N extends ActionName> = Actions[N]["result"];

/** A Context whose params/meta are the ones declared for `N`. */
export type Ctx<N extends ActionName> = Context<ActionParams<N>, Meta>;
/** Same for event handlers: ctx.params is the event payload. */
export type EventCtx<E extends keyof Events> = Context<Events[E], Meta>;

export function call<N extends ActionName>(
  caller: ServiceBroker | Context<unknown, Meta>,
  action: N,
  params: ActionParams<N>,
  opts?: CallingOptions,
): Promise<ActionResult<N>> {
  return caller.call<ActionResult<N>, ActionParams<N>>(action, params, opts);
}

export function emit<E extends keyof Events>(ctx: Context<unknown, Meta>, event: E, payload: Events[E]) {
  return ctx.emit(event, payload);
}
Enter fullscreen mode Exit fullscreen mode

This is the whole trick. Everything else in the article builds on it.

Types are shared through a file, not through the wire. Runtime validation guards the boundary.

Step 2: a service in schema style

The schema object is Moleculer's native idiom. In TypeScript you annotate it as ServiceSchema<Settings, Methods>, and this.settings.currency and this.findOrFail() become type-checked inside handlers.

// services/products.service.ts
import type { ServiceSchema, Context } from "moleculer";
import type { Product, Meta } from "../src/contracts";

interface Settings { currency: string; }
interface Methods { findOrFail(id: number): Product; }

const catalogue: Product[] = [
  { id: 1, name: "Keyboard", price: 79 },
  { id: 2, name: "Monitor", price: 329 },
  { id: 3, name: "Cable", price: 9 },
];

const ProductsService: ServiceSchema<Settings, Methods> = {
  name: "products",
  settings: { currency: "EUR" },

  actions: {
    get: {
      // Runtime validation — the compiler cannot check what arrives over the wire.
      params: { id: { type: "number", convert: true } },
      handler(ctx: Context<{ id: number }, Meta>): Product {
        this.logger.info(`get #${ctx.params.id} for user ${ctx.meta.userId ?? "anonymous"}`);
        return this.findOrFail(ctx.params.id);          // typed via Methods
      },
    },
    list: {
      params: { limit: { type: "number", optional: true, convert: true } },
      handler(ctx: Context<{ limit?: number }, Meta>): Product[] {
        return catalogue.slice(0, ctx.params.limit ?? catalogue.length);
      },
    },
  },

  methods: {
    findOrFail(id) {
      const p = catalogue.find(p => p.id === id);
      if (!p) throw new Error(`Product ${id} not found`);
      return p;
    },
  },

  started() {
    this.logger.info(`products up, prices in ${this.settings.currency}`);   // typed via Settings
  },
};

export default ProductsService;
Enter fullscreen mode Exit fullscreen mode

Two things to notice. The params block is runtime validation (Moleculer uses fastest-validator) and it stays there no matter how much TypeScript you add — the compiler has no idea what another process will send. And the Context<{ id: number }, Meta> annotation on the handler is what makes ctx.params.id a number rather than unknown; you could use the Ctx<"products.get"> alias from the wrapper instead, which is what the next service does.

Step 3: a service in class style

If your team thinks in classes, extend Service and pass the schema from the constructor. Handlers are plain methods, this is the class, and private state is a field.

// services/orders.service.ts
import { Service, ServiceBroker } from "moleculer";
import type { Order } from "../src/contracts";
import { call, emit, type Ctx, type EventCtx } from "../src/typed-broker";

export default class OrdersService extends Service {
  private orders: Order[] = [];

  constructor(broker: ServiceBroker) {
    super(broker);
    this.parseServiceSchema({
      name: "orders",
      actions: {
        create: {
          params: {
            productId: { type: "number", convert: true },
            quantity: { type: "number", min: 1, convert: true },
          },
          handler: this.create,
        },
      },
      events: {
        "order.created": this.onOrderCreated,
      },
    });
  }

  async create(ctx: Ctx<"orders.create">): Promise<Order> {
    // Typed call: the action name autocompletes, params are checked, the result is a Product.
    const product = await call(ctx, "products.get", { id: ctx.params.productId });

    const order: Order = {
      id: `ord_${this.orders.length + 1}`,
      productId: product.id,
      quantity: ctx.params.quantity,
      total: product.price * ctx.params.quantity,
    };
    this.orders.push(order);

    await emit(ctx, "order.created", order);   // payload must be an Order
    return order;
  }

  onOrderCreated(ctx: EventCtx<"order.created">) {
    this.logger.info(`event: order ${ctx.params.id} created, total ${ctx.params.total}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Moleculer binds handlers to the service instance, so passing this.create as the handler is fine — inside it, this.orders and this.logger are what you expect.

Step 4: a typed caller

// src/client.ts
import { ServiceBroker } from "moleculer";
import { call } from "./typed-broker";

const broker = new ServiceBroker({
  nodeID: `client-${process.pid}`,
  transporter: { type: "NATS", options: { url: "nats://localhost:4222" } },
  logger: false,
});

async function main() {
  await broker.start();
  await broker.waitForServices(["products", "orders"]);

  const product = await call(broker, "products.get", { id: 2 });
  console.log("product:", product.name, product.price);        // product: Product

  const order = await call(broker, "orders.create", { productId: 2, quantity: 3 });
  console.log("order:", order.id, "total", order.total);       // order: Order

  // Runtime validation still guards the wire: force a bad payload past the compiler.
  try {
    await call(broker, "products.get", { id: "two" as unknown as number });
  } catch (err: any) {
    console.log("rejected:", err.name, "-", err.data?.[0]?.message);
  }

  await broker.stop();
}
main();
Enter fullscreen mode Exit fullscreen mode

And here is what the compiler says when you get it wrong — this is the payoff of the contract file:

await call(broker, "products.get", { id: "2" });     // wrong param type
await call(broker, "product.get", { id: 2 });        // typo in the action name
const order = await call(broker, "orders.create", { productId: 2, quantity: 1 });
console.log(order.totl);                             // typo in a result field
Enter fullscreen mode Exit fullscreen mode
$ npx tsc --noEmit -p .
src/mistakes.ts(8,40): error TS2322: Type 'string' is not assignable to type 'number'.
src/mistakes.ts(9,22): error TS2345: Argument of type '"product.get"' is not assignable to parameter of type 'keyof Actions'.
src/mistakes.ts(11,21): error TS2551: Property 'totl' does not exist on type 'Order'. Did you mean 'total'?
Enter fullscreen mode Exit fullscreen mode

Three classes of bug that used to be runtime failures in another process are now red squiggles in the editor.

Step 5: running it

Development: run the .ts sources directly. moleculer-runner resolves .ts config and service files; it just needs a loader in the process, and tsx is the least fuss:

// moleculer.config.ts
import type { BrokerOptions } from "moleculer";

const config: BrokerOptions = {
  nodeID: `node-${process.pid}`,
  transporter: { type: "NATS", options: { url: process.env.TRANSPORTER ?? "nats://localhost:4222" } },
  logger: { type: "Console", options: { formatter: "short" } },
  requestTimeout: 2000,
};
export default config;
Enter fullscreen mode Exit fullscreen mode
$ node --import tsx node_modules/.bin/moleculer-runner --config moleculer.config.ts --mask "**/*.service.ts" services
[17:49:58.859Z] INFO  PRODUCTS: products up, prices in EUR
[17:49:58.868Z] INFO  REGISTRY: 'orders' service is registered.
[17:49:58.869Z] INFO  REGISTRY: 'products' service is registered.
[17:49:58.870Z] INFO  BROKER: ✔ ServiceBroker with 3 service(s) started successfully in 869ms.

$ npx tsx src/client.ts
product: Monitor 329
order: ord_1 total 987
rejected: ValidationError - The 'id' field must be a number.
Enter fullscreen mode Exit fullscreen mode

The --mask matters: the runner's default file mask is **/*.service.js, so without it your .ts services are silently skipped.

Production: compile once, ship JavaScript, run with plain Node. No loader in the hot path.

$ npx tsc -p .
$ node node_modules/.bin/moleculer-runner --config dist/moleculer.config.js dist/services
$ node dist/src/client.js
product: Monitor 329
order: ord_1 total 987
rejected: ValidationError - The 'id' field must be a number.
Enter fullscreen mode Exit fullscreen mode

Same output, same services. In Docker this is a two-stage build: tsc in the first stage, only dist/ and production dependencies in the second.

The honest caveats

1. No decorators in core. If you want @Action() / @Event() on class methods, the community package moleculer-decorators does it. I tested version 1.3.0 against Moleculer 0.15.2 and it works:

import { ServiceBroker, Context, Service as MoleculerService } from "moleculer";
import { Service, Action, Event, Method } from "moleculer-decorators";

@Service({ name: "greeter", settings: { greeting: "Hello" } })
class GreeterService extends MoleculerService<{ greeting: string }> {
  @Action({ params: { name: "string" } })
  hello(ctx: Context<{ name: string }>) {
    return this.format(ctx.params.name);
  }

  @Method
  format(name: string) {
    return `${this.settings.greeting}, ${name}!`;
  }

  @Event()
  "user.signup"(ctx: Context<{ name: string }>) {
    this.logger.info(`welcome mail to ${ctx.params.name}`);
  }
}
Enter fullscreen mode Exit fullscreen mode
$ npx tsx greeter.service.ts
Hello, Ada!
Enter fullscreen mode Exit fullscreen mode

But be aware of what you're taking on: the package's last release was in 2022, it declares a dependency on Moleculer 0.14, and it uses the legacy experimentalDecorators flag rather than the standard (TC39) decorators that TypeScript 5+ supports. It is small and readable, so forking it is a realistic fallback — but it is not maintained by the core team, and I won't pretend otherwise. If decorators are a hard requirement for your team, weigh that.

2. Types don't cross the wire. The Actions interface is a promise you make to yourself. If the products service is deployed with a new field and the caller isn't, the compiler is happy and the runtime is what it is. Keep the contract file in a shared package (or a monorepo) and version it with the services. Runtime params validation stays mandatory — it is the only thing that checks what actually arrived.

3. Some option types are stricter than the runtime. Example you'll hit in the first five minutes: transporter: "nats://localhost:4222" is the documented shorthand and works at runtime, but BrokerOptions["transporter"] doesn't accept an arbitrary string, so under strict you'll get a type error. Use the object form { type: "NATS", options: { url } }, which is better anyway once you have more than one option. Expect a handful of these; the typings are hand-written and improve release by release (0.15 reworked them — this is typed in lifecycle handlers, for instance), and pull requests to them are the easiest way to contribute to the project.

4. No dependency injection. Moleculer has mixins for shared behaviour and this.broker for reaching other services, not a DI container. If your architecture leans on injectable providers and testing through container overrides, that's a real difference — see the NestJS comparison for when it matters.

5. this typing depends on you. Inside a schema, this is only as typed as the generics you pass to ServiceSchema. Skip them and this.settings.anything is any. It's a small discipline, but it is a discipline.

Recommendations

  • Start with the contract file + typed wrapper. It's the highest-value 50 lines in a Moleculer TypeScript project and it works with either service style.
  • Use schema style by default; it matches the docs and every example on the internet. Use class style when a service has enough private state and helpers that a class reads better.
  • Use Ctx<"action.name"> for handler signatures so the params type is defined once, in the contract, not repeated per handler.
  • tsx in development, tscdist/ in production. Don't ship a TypeScript loader to prod.
  • Keep strict: true and skipLibCheck: true — the second one saves you from type-checking your entire node_modules on every build.

If you already have a JavaScript Moleculer project, none of this requires a rewrite: rename a file to .ts, add the contract for the actions it calls, and go one service at a time.


Code in this article was compiled with TypeScript 7.0 (strict: true) and run on Moleculer 0.15.2, Node.js 22 and NATS 2. The full example, including a run.sh that exercises both the dev and the production path, is in the moleculer-examples repository.

Top comments (0)