Skip to content

@mmmnt emit ts

Claude edited this page Mar 27, 2026 · 2 revisions

@mmmnt/emit-ts

Emits TypeScript type definitions, aggregate interfaces, barrel files, and test scaffolds from the IR and TestSuiteTopology.

Package path: packages/emit-ts Milestone: M3 (v0.1.0-m3)


Responsibilities

@mmmnt/emit-ts owns all TypeScript output:

  1. TypeScriptEmitter -- IR --> .types.ts, .aggregate.ts, index.ts
  2. TestScaffoldEmitter -- IR + topology --> .spec.ts files (flow + aggregate scaffolds)
  3. Policy -- EmitTypeScriptOnTopologyDerived wires both emitters as a TopologyDerivedHook

The TestScaffoldEmitter is the sole owner of all .spec.ts production (invariant SG-01).

Exports

// Types
export type {
  GenerationScope,
  GenerationResult,
  TypeScriptConvention,
  TestScaffoldResult,
} from './types/index.js';

// Emitters
export { TypeScriptEmitter } from './services/typescript-emitter.js';
export type { EmitOptions, TypeScriptEmitterOutput } from './services/typescript-emitter.js';
export { TestScaffoldEmitter } from './services/test-scaffold-emitter.js';
export type { TestScaffoldEmitterOutput } from './services/test-scaffold-emitter.js';

// Policy
export { EmitTypeScriptOnTopologyDerived } from './policies/emit-typescript-on-topology-derived.js';
export type { EmitTypeScriptResult } from './policies/emit-typescript-on-topology-derived.js';

TypeScriptEmitter

Transforms IR contexts and aggregates into TypeScript interface files.

import { TypeScriptEmitter } from '@mmmnt/emit-ts';

const emitter = new TypeScriptEmitter();
const output = emitter.emit(ir, { scope: { level: 'system' } });

for (const [path, content] of output.files) {
  console.log(`${path}:`);
  console.log(content);
}

console.log(`Files written: ${output.result.filesWritten.length}`);

Emission Invariants

Rule Description
TG-01 Exact specification vocabulary in emitted types -- no renaming
TG-02 Deterministic: same IR always produces identical output
TG-04 All value object fields are readonly
TG-05 dryRun support -- compute output without side effects

Output Structure

src/
  {context-name}/                         # kebab-case context name
    {aggregate-name}.types.ts             # VO, command, event interfaces
    {aggregate-name}.aggregate.ts         # Aggregate root interface
    index.ts                              # Re-exports all types + aggregates

Generated .types.ts Example

For an aggregate with a command, event, and value object:

/**
 * Types for the Order aggregate.
 */

export interface OrderItem {
  readonly productId: string;
  readonly quantity: number;
  readonly unitPrice: Money;
}

export interface Address {
  readonly street: string;
  readonly city: string;
  readonly state: string;
  readonly postalCode: string;
  readonly country: string;
}

export interface PlaceOrder {
  readonly customerId: string;
  readonly items: readonly OrderItem[];
  readonly shippingAddress: Address;
}

export interface OrderPlaced {
  readonly orderId: string;
  readonly customerId: string;
  readonly items: readonly OrderItem[];
  readonly shippingAddress: Address;
  readonly placedAt: Date;
}

Generated .aggregate.ts Example

import type { PlaceOrder, OrderPlaced, OrderItem, Address } from './order.types.js';

/**
 * Aggregate root for Order.
 */
export interface OrderAggregate {
  readonly orderId: string;
  placeOrder(command: PlaceOrder): OrderPlaced;
}

The aggregate interface:

  • Imports all referenced types from the .types.ts file (deduplicated and sorted)
  • Exposes the identity field as readonly
  • Has one method per command, taking the command type and returning the emitted event type
  • Method names use camelCase (first character lowercased from PascalCase command name)

Generated index.ts Example

export * from './order.types.js';
export * from './order.aggregate.js';

Generation Scope

Control what gets emitted with GenerationScope:

interface GenerationScope {
  readonly level: 'system' | 'context' | 'aggregate';
  readonly targets?: string[];
}

// Emit everything
emitter.emit(ir, { scope: { level: 'system' } });

// Emit only the Ordering context
emitter.emit(ir, { scope: { level: 'context', targets: ['Ordering'] } });

// Emit only the Order aggregate
emitter.emit(ir, { scope: { level: 'aggregate', targets: ['Order'] } });

// Dry run (no side effects, just compute)
emitter.emit(ir, { scope: { level: 'system' }, dryRun: true });

Type Mapping

DSL Type TypeScript Type
string string
number number
boolean boolean
UUID string
DateTime Date
Date Date
Money Money
int number
float number
decimal number
Custom (e.g., OrderItem) PascalCase (e.g., OrderItem)
Array (e.g., OrderItem[]) readonly OrderItem[]

Safety Functions

The emitter uses several safety functions to handle edge cases:

Function Purpose
safePropertyKey(name) Wraps non-identifier property names in quotes
safePathSegment(name) Sanitizes names for use in file paths
sanitizeJsDoc(text) Escapes */ in JSDoc comments
toPascalCase(name) Converts any string to PascalCase
toKebabCase(name) Converts PascalCase to kebab-case for file names

TestScaffoldEmitter

Generates Vitest test scaffold files from IR and topology.

import { TestScaffoldEmitter } from '@mmmnt/emit-ts';

const emitter = new TestScaffoldEmitter();
const output = emitter.emit(ir, topology);

for (const [path, content] of output.files) {
  console.log(`${path}:`);
  console.log(content);
}

console.log(`Spec files: ${output.result.specFilesWritten.length}`);
console.log(`Scenarios: ${output.result.scenariosGenerated}`);

Output Structure

__tests__/
  flows/
    {flow-name}.spec.ts             # Per-flow integration scaffolds
  {context-name}/
    {aggregate-name}.spec.ts        # Per-aggregate unit scaffolds

Generated Flow Spec Example

import { describe, it, beforeEach, expect } from 'vitest';

describe('order-to-shipment', () => {
  describe('Order placement', () => {
    it('should verify OrderPlaced crosses from Ordering to Fulfillment', () => {
      // Crossing: conn-ordering-fulfillment-orderplaced
      // Assertion type: payload
      // Expected fields:
      //   orderId: UUID (required)
      //   items: OrderItem[] (required)
      //   shippingAddress: Address (required)
      // TODO: implement assertion
    });
  });

  describe('Inventory check', () => {
    // No crossings in this frame -- no assertions generated
  });

  describe('Inventory check outcome', () => {
    it('should verify FulfillmentReady crosses from Fulfillment to Shipping', () => {
      // Crossing: conn-fulfillment-shipping-fulfillmentready
      // Assertion type: payload
      // Expected fields:
      //   orderId: UUID (required)
      //   reservedItems: ReservedItem[] (required)
      // TODO: implement assertion
    });
  });
});

Generated Aggregate Spec Example

import { describe, it, expect } from 'vitest';

describe('Order', () => {
  it('should handle PlaceOrder', () => {
    // TODO: implement
  });

  it('should enforce invariant: Order must contain at least one item', () => {
    // TODO: implement invariant violation test
  });

  it('should enforce invariant: Shipping address must be complete', () => {
    // TODO: implement invariant violation test
  });
});

Scaffold Features

Feature Description
Flow scaffolds describe per flow, nested describe per frame, it per crossing assertion
Setup steps beforeEach blocks with setup comments when preconditions exist
Assertion details Comments with crossing ID, assertion type, expected fields
Aggregate scaffolds it per command handler, it per invariant violation
String safety escapeStringLiteral for test descriptions, sanitizeComment for inline comments

Invariant TG-03

Every flow produces a .spec.ts file. This is the test scaffold counterpart of the Gherkin generator's GN-01 rule. Together, they enforce Design Principle #2.


EmitTypeScriptOnTopologyDerived Policy

This policy conforms to TopologyDerivedHook and invokes both emitters:

import { EmitTypeScriptOnTopologyDerived } from '@mmmnt/emit-ts';

const policy = new EmitTypeScriptOnTopologyDerived();

// As a TopologyDerivedHook callback (void return):
policy.handle(topology, ir);

// As a direct call (returns EmitTypeScriptResult):
const result = policy.execute(ir, topology);
console.log(`Types: ${result.typeScriptOutput.result.filesWritten.length} files`);
console.log(`Scaffolds: ${result.scaffoldOutput.result.specFilesWritten.length} files`);

EmitTypeScriptResult

interface EmitTypeScriptResult {
  readonly typeScriptOutput: TypeScriptEmitterOutput;
  readonly scaffoldOutput: TestScaffoldEmitterOutput;
}

The policy always emits at system scope (all contexts, all aggregates). For scoped emission, use TypeScriptEmitter directly.

Further Reading

Clone this wiki locally