noddde

Type Inference Helpers

Type inference helpers for extracting types from definitions and typing extracted handlers in separate files

Overview

noddde provides five type-level helpers that extract specific types from an aggregate definition. These are useful when you need to reference an aggregate's types outside of its definition file -- for example, in tests, projections, API layers, or shared utility types.

All five helpers are exported from @noddde/core:

import {
  InferAggregateID,
  InferAggregateState,
  InferAggregateEvents,
  InferAggregateCommands,
  InferAggregateInfrastructure,
} from "@noddde/core";

InferAggregateState

Extracts the state type from an Aggregate instance.

type InferAggregateState<T extends Aggregate> =
  T extends Aggregate<infer U> ? U["state"] : never;

Usage

import { InferAggregateState } from "@noddde/core";
import { BankAccount } from "./bank-account";

type BankAccountState = InferAggregateState<typeof BankAccount>;
// {
//   balance: number;
//   availableBalance: number;
//   transactions: Array<{
//     id: string;
//     timestamp: Date;
//     amount: number;
//     merchant: string;
//     status: "pending" | "processed" | "declined";
//   }>;
// }

When to Use

  • Tests: Assert that applying events produces the expected state shape
  • Projections: Type your read model transformations against the source aggregate state
  • API responses: Derive response types from aggregate state without duplicating the interface
// In a test file
import { InferAggregateState } from "@noddde/core";
import { BankAccount } from "./bank-account";

type State = InferAggregateState<typeof BankAccount>;

function assertState(actual: State, expected: Partial<State>) {
  expect(actual).toMatchObject(expected);
}

InferAggregateEvents

Extracts the event union type from an Aggregate instance.

type InferAggregateEvents<T extends Aggregate> =
  T extends Aggregate<infer U> ? U["events"] : never;

Usage

import { InferAggregateEvents } from "@noddde/core";
import { BankAccount } from "./bank-account";

type BankAccountEvent = InferAggregateEvents<typeof BankAccount>;
// | { name: "BankAccountCreated"; payload: { id: string } }
// | { name: "TransactionAuthorized"; payload: { id: string; ... } }
// | { name: "TransactionDeclined"; payload: { id: string; ... } }
// | { name: "TransactionProcessed"; payload: { id: string; ... } }

When to Use

  • Event handlers: Type standalone event handlers or projection handlers that react to this aggregate's events
  • Event bus subscribers: Ensure subscribers handle the correct event shapes
  • Narrowing: Use discriminated union narrowing on the name field
type BankEvent = InferAggregateEvents<typeof BankAccount>;

function handleEvent(event: BankEvent) {
  switch (event.name) {
    case "TransactionAuthorized":
      // event.payload is narrowed to
      // { id: string; timestamp: Date; amount: number; merchant: string }
      console.log(`Authorized: ${event.payload.amount}`);
      break;
    case "TransactionDeclined":
      console.log(`Declined: ${event.payload.merchant}`);
      break;
  }
}

InferAggregateCommands

Extracts the command union type from an Aggregate instance.

type InferAggregateCommands<T extends Aggregate> =
  T extends Aggregate<infer U> ? U["commands"] : never;

Usage

import { InferAggregateCommands } from "@noddde/core";
import { BankAccount } from "./bank-account";

type BankAccountCommand = InferAggregateCommands<typeof BankAccount>;
// | { name: "CreateBankAccount"; targetAggregateId: string }
// | { name: "AuthorizeTransaction"; targetAggregateId: string;
//     payload: { amount: number; merchant: string } }

When to Use

  • API layer: Type your command DTOs to match the aggregate's command shapes
  • Command factories: Build helper functions that construct valid commands
  • Validation: Ensure incoming requests match the expected command structure
type BankCommand = InferAggregateCommands<typeof BankAccount>;

// A typed command factory
function authorizeTransaction(
  accountId: string,
  amount: number,
  merchant: string,
): Extract<BankCommand, { name: "AuthorizeTransaction" }> {
  return {
    name: "AuthorizeTransaction",
    targetAggregateId: accountId,
    payload: { amount, merchant },
  };
}

InferAggregateID

Extracts the type of targetAggregateId from the aggregate's command types. This operates on the AggregateTypes bundle rather than the Aggregate instance.

type InferAggregateID<T extends AggregateTypes> =
  T["commands"]["targetAggregateId"];

Usage

import { InferAggregateID } from "@noddde/core";

// Using the AggregateTypes bundle directly
type BankAccountId = InferAggregateID<BankAccountDef>;
// string (default)

// With a custom ID type
type BrandedId = string & { __brand: "AuctionId" };
type AuctionDef = {
  state: AuctionState;
  events: AuctionEvent;
  commands: DefineCommands<
    {
      /* ... */
    },
    BrandedId
  >;
  infrastructure: {};
};

type AuctionId = InferAggregateID<AuctionDef>;
// string & { __brand: "AuctionId" }

When to Use

  • Repository interfaces: Type the ID parameter of load/save methods
  • URL parameters: Ensure route handlers parse the correct ID type
  • Cross-aggregate references: Type references between aggregates
type AccountId = InferAggregateID<BankAccountDef>;

interface BankAccountRepository {
  getById(id: AccountId): Promise<BankAccountView>;
  listByCustomer(customerId: string): Promise<BankAccountView[]>;
}

Note that InferAggregateID takes an AggregateTypes bundle (the type you pass to defineAggregate<T>), not the result of defineAggregate. This is because the ID type lives on the command definitions, which are part of the types bundle.

InferAggregateInfrastructure

Extracts the infrastructure type from an Aggregate instance.

type InferAggregateInfrastructure<T extends Aggregate> =
  T extends Aggregate<infer U> ? U["infrastructure"] : never;

Usage

import { InferAggregateInfrastructure } from "@noddde/core";
import { Auction } from "./auction";

type AuctionInfra = InferAggregateInfrastructure<typeof Auction>;
// { clock: { now(): Date } }

When to Use

  • Test setup: Know exactly which infrastructure services to mock
  • Infrastructure factories: Type helper functions that build the infrastructure object for a specific aggregate
type AuctionInfra = InferAggregateInfrastructure<typeof Auction>;

function createTestInfrastructure(): AuctionInfra {
  return {
    clock: {
      now: () => new Date("2025-01-15T10:00:00Z"),
    },
  };
}

Handler-Level Inference

When you extract decide handlers, evolve handlers, saga handlers, or projection handlers to separate files, you need to type the function signature. The handler-level inference utilities derive the exact function type from your *Types bundle and a command/event/query name -- no manual reconstruction needed.

InferDecideHandler

Types an extracted aggregate decide handler:

import type { InferDecideHandler } from "@noddde/core";

type BookingDef = {
  state: BookingState;
  events: BookingEvent;
  commands: BookingCommand;
  infrastructure: HotelInfrastructure;
};

// Before: manual, verbose
export const decideConfirmBooking = (
  command: { targetAggregateId: string; payload: ConfirmBookingPayload },
  state: BookingState,
  { clock }: HotelInfrastructure,
): BookingEvent => {
  /* ... */
};

// After: one annotation, full inference
export const decideConfirmBooking: InferDecideHandler<
  BookingDef,
  "ConfirmBooking"
> = (command, state, { clock }) => {
  /* ... */
};

InferEvolveHandler

Types an extracted aggregate evolve handler (event reducer):

import type { InferEvolveHandler } from "@noddde/core";

export const evolveBookingConfirmed: InferEvolveHandler<
  BookingDef,
  "BookingConfirmed"
> = (payload, state) => ({
  ...state,
  status: "confirmed",
  roomId: payload.roomId,
});

InferSagaEventHandler and InferSagaOnEntry

Type extracted saga handlers. InferSagaEventHandler types just the handler function; InferSagaOnEntry types the full { id, handle } bundle:

import type { InferSagaOnEntry } from "@noddde/core";

export const onOrderPlaced: InferSagaOnEntry<FulfillmentDef, "OrderPlaced"> = {
  id: (event) => event.payload.orderId,
  handle: (event, state) => ({
    state: { ...state, status: "awaiting_payment" },
    commands: { name: "RequestPayment", targetAggregateId: event.payload.orderId, payload: { ... } },
  }),
};

InferProjectionEventHandler and InferProjectionQueryHandler

Type extracted projection handlers:

import type {
  InferProjectionEventHandler,
  InferProjectionQueryHandler,
} from "@noddde/core";

export const onPaymentCompleted: InferProjectionEventHandler<
  RevenueDef,
  "PaymentCompleted"
> = {
  id: (event) => day(event.payload.completedAt),
  reduce: (event, view) => ({
    ...view,
    totalRevenue: view.totalRevenue + event.payload.amount,
  }),
};

export const queryDailyRevenue: InferProjectionQueryHandler<
  RevenueDef,
  "GetDailyRevenue"
> = (query, { views }) => views.load(query.date);

InferProjectionQueryHandler automatically injects { views } into the infrastructure parameter when your ProjectionTypes bundle has a viewStore field.

InferProjectionQueryInfrastructure

Computes the full infrastructure type for a projection's query handlers, conditionally including { views }:

import type { InferProjectionQueryInfrastructure } from "@noddde/core";

type Infra = InferProjectionQueryInfrastructure<RevenueDef>;
// → RevenueInfra & { views: RevenueViewStore }  (when viewStore is present)
// → RevenueInfra                                 (when viewStore is absent)

Quick Reference

Definition-level (extract types from built definitions)

HelperInputExtracts
InferAggregateState<typeof Agg>Aggregate instanceThe state type
InferAggregateEvents<typeof Agg>Aggregate instanceThe event union type
InferAggregateCommands<typeof Agg>Aggregate instanceThe command union type
InferAggregateID<Def>AggregateTypes bundleThe targetAggregateId type
InferAggregateInfrastructure<typeof Agg>Aggregate instanceThe infrastructure type

Map-level (extract types across aggregate/projection maps)

HelperInputExtracts
InferAggregateMapCommands<typeof aggMap>Record<string, Aggregate> mapUnion of all command types across aggregates
InferProjectionMapQueries<typeof projMap>Record<string, Projection> mapUnion of all query types across projections

These are used internally by wireDomain to compute the typed dispatch constraints. You can also use them directly:

import type {
  InferAggregateMapCommands,
  InferProjectionMapQueries,
} from "@noddde/core";

const aggregates = { Counter, Todo } as const;
type AllCommands = InferAggregateMapCommands<typeof aggregates>;
// CounterCommand | TodoCommand

const projections = { ItemProjection, OrderProjection } as const;
type AllQueries = InferProjectionMapQueries<typeof projections>;
// ItemQuery | OrderQuery

Handler-level (type extracted handlers in separate files)

HelperInputExtracts
InferDecideHandler<Def, "CmdName">AggregateTypes bundleDecide handler function type
InferEvolveHandler<Def, "EventName">AggregateTypes bundleEvolve handler function type
InferSagaEventHandler<Def, "EventName">SagaTypes bundleSaga event handler function type
InferSagaOnEntry<Def, "EventName">SagaTypes bundleSaga on-entry { id, handle } object type
InferProjectionEventHandler<Def, "EventName">ProjectionTypes bundleProjection { id?, reduce } object type
InferProjectionQueryHandler<Def, "QueryName">ProjectionTypes bundleQuery handler function type (with views)
InferProjectionQueryInfrastructure<Def>ProjectionTypes bundleConditional infrastructure (with/without views)

Definition-level helpers work with typeof YourAggregate (the value returned by defineAggregate). Handler-level helpers and InferAggregateID work with the *Types bundle (the type alias you pass as the generic parameter).

Combining Helpers

You can combine these helpers to build comprehensive utility types:

import {
  InferAggregateState,
  InferAggregateEvents,
  InferAggregateCommands,
} from "@noddde/core";
import { BankAccount } from "./bank-account";

// A complete type map for external consumers
interface BankAccountTypes {
  state: InferAggregateState<typeof BankAccount>;
  events: InferAggregateEvents<typeof BankAccount>;
  commands: InferAggregateCommands<typeof BankAccount>;
}

// Extract a specific command by name
type AuthorizeCmd = Extract<
  InferAggregateCommands<typeof BankAccount>,
  { name: "AuthorizeTransaction" }
>;

// Extract a specific event by name
type AuthorizedEvent = Extract<
  InferAggregateEvents<typeof BankAccount>,
  { name: "TransactionAuthorized" }
>;

Next Steps

On this page