Skip to content

refactor(types): replace 62 as-any casts with type-safe event dispatch - #140

Merged
joshuawheelock merged 3 commits into
jumbocontext:mainfrom
Saturate:fix/type-safe-event-bus
Jun 4, 2026
Merged

refactor(types): replace 62 as-any casts with type-safe event dispatch#140
joshuawheelock merged 3 commits into
jumbocontext:mainfrom
Saturate:fix/type-safe-event-bus

Conversation

@Saturate

@Saturate Saturate commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

The ProjectionBusFactory had 62 as any casts, one per event subscription. Each cast silenced the compiler at the boundary between the generic BaseEvent bus and the specific event types that projectors expect. If a payload field was renamed or removed, TypeScript wouldn't catch the mismatch.

This PR adds a generic on<E>() helper that concentrates the single unavoidable cast (as E) inside one function, while making every call site type-safe:

// before
bus.subscribe("GoalAddedEvent", this.wrap((e) => projector.applyGoalAdded(e as any)));

// after
on<GoalAddedEvent>(bus, GoalEventType.ADDED, (e) => projector.applyGoalAdded(e));

The subscription also now uses the event type constants (GoalEventType.ADDED) instead of string literals, which catches typos at compile time.

Total any count in the codebase dropped from 203 to 141. Build passes, all 2664 tests pass.

Codemod used to produce this change

The event type constant values are always the event class name (e.g. GoalEventType.ADDED = "GoalAddedEvent"), so the codemod derives import paths and constant references from the string literal in each bus.subscribe() call. No lookup table needed.

Save as codemod.mjs and run with node codemod.mjs:

#!/usr/bin/env node
import fs from "node:fs";

const FILE = "src/infrastructure/messaging/ProjectionBusFactory.ts";
let src = fs.readFileSync(FILE, "utf-8");

// Derive import paths from event name convention:
//   "GoalAddedEvent" -> domain: "goals", action: "add", constant key: "ADDED"
//   "SessionStartedEvent" -> domain: "sessions", action: "start", constant key: "STARTED"
const DOMAIN_MAP = {
  Goal: "goals", Session: "sessions", Architecture: "architecture",
  Component: "components", Decision: "decisions", Dependency: "dependencies",
  Guideline: "guidelines", Invariant: "invariants", Project: "project",
  AudiencePain: "audience-pains", Audience: "audiences",
  ValueProposition: "value-propositions", Relation: "relations", Worker: "workers",
};
const ACTION_MAP = {
  Added: "add", Started: "start", Updated: "update", Blocked: "block",
  Unblocked: "unblock", Paused: "pause", Resumed: "resume", Completed: "complete",
  Refined: "refine", RefinementStarted: "refine", Reset: "reset", Removed: "remove",
  ProgressUpdated: "update-progress", SubmittedForReview: "review",
  Qualified: "qualify", Committed: "commit", Rejected: "reject",
  Submitted: "submit", CodifyingStarted: "codify", Closed: "close",
  Approved: "approve", StatusMigrated: "migrate", Defined: "define",
  Deprecated: "deprecate", Undeprecated: "undeprecate", Renamed: "rename",
  Reversed: "reverse", Restored: "restore", Superseded: "supersede",
  Initialized: "init", Deactivated: "deactivate", Reactivated: "reactivate",
  Identified: "identify",
};
const CONST_KEY_MAP = {
  RefinementStarted: "REFINEMENT_STARTED", ProgressUpdated: "PROGRESS_UPDATED",
  SubmittedForReview: "SUBMITTED_FOR_REVIEW", CodifyingStarted: "CODIFYING_STARTED",
  StatusMigrated: "STATUS_MIGRATED",
};

function parseEventName(name) {
  for (const [prefix, domain] of Object.entries(DOMAIN_MAP).sort((a, b) => b[0].length - a[0].length)) {
    if (!name.startsWith(prefix)) continue;
    const rest = name.slice(prefix.length).replace(/Event$/, "");
    const action = ACTION_MAP[rest];
    if (!action) continue;
    const constKey = CONST_KEY_MAP[rest] || rest.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase();
    const constImport = `${prefix}EventType`;
    const isWorker = domain === "workers";
    const constPath = isWorker
      ? `../../domain/workers/identify/WorkerIdentifiedEvent.js`
      : `../../domain/${domain}/Constants.js`;
    const eventPath = `../../domain/${domain}/${action}/${name}.js`;
    return { constImport, constPath, constant: `${constImport}.${constKey}`, eventPath };
  }
  throw new Error(`Cannot parse event: ${name}`);
}

const events = new Map(), consts = new Map();
let count = 0;

src = src.replace(
  /bus\.subscribe\("(\w+Event)",\s*this\.wrap\(\(e\)\s*=>\s*(\w+)\.(\w+)\(e as any\)\)\)/g,
  (_, eventName, proj, method) => {
    const info = parseEventName(eventName);
    events.set(eventName, info.eventPath);
    consts.set(info.constImport, info.constPath);
    count++;
    return `on<${eventName}>(bus, ${info.constant}, (e) => ${proj}.${method}(e))`;
  }
);

// Remove wrap() method
src = src.replace(
  /\n\s*\/\*\*\s*\n\s*\* Wraps a projector method as an IEventHandler\.\s*\n\s*\*\/\s*\n\s*private wrap\([^)]+\)[^}]+\}\s*\n\s*\}/,
  ""
);

// Add helper before class
src = src.replace("export class ProjectionBusFactory {",
`\nfunction on<E extends BaseEvent>(
  bus: InProcessEventBus,
  eventType: E["type"],
  handler: (event: E) => void | Promise<void>,
): void {
  bus.subscribe(eventType, {
    handle: async (event: BaseEvent) => { await handler(event as E); },
  });
}

export class ProjectionBusFactory {`);

// Add imports
const imports = [
  ...[...consts].sort((a, b) => a[0].localeCompare(b[0])).map(([n, p]) => `import { ${n} } from "${p}";`),
  ...[...events].sort((a, b) => a[0].localeCompare(b[0])).map(([n, p]) => `import type { ${n} } from "${p}";`),
];
const last = src.lastIndexOf("\nimport ");
const nl = src.indexOf("\n", last + 1);
src = src.slice(0, nl) + "\n\n" + imports.join("\n") + src.slice(nl);

// Remove unused IEventHandler import
src = src.replace(/import \{ IEventHandler \} from "[^"]+"\;\n/, "");

fs.writeFileSync(FILE, src);
console.log(`Transformed ${count} calls. Run: npm run build && npm test`);

Add a generic on<E>() helper that narrows BaseEvent to the specific
event type at each subscription site. The single unavoidable cast
(as E) is now inside the helper instead of scattered across 62 call
sites in ProjectionBusFactory.
@joshuawheelock
joshuawheelock merged commit 7e91727 into jumbocontext:main Jun 4, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants