TypeDrop

2026-04-11 Challenge

2026-04-11 Hard

Typed Event Sourcing Ledger

You're building the audit-log replay engine for a fintech platform. Raw domain events arrive as unknown JSON streams; your engine must validate them, fold them into a strongly-typed account ledger via a discriminated-union event bus, and expose a typed projection API — with zero `any`.

Goals

  • Define all branded types, discriminated-union events, and a generic Result<T,E> type with zero `any`.
  • Implement fail-fast runtime validators that narrow `unknown` inputs to fully-typed `DomainEvent` values.
  • Implement `applyEvent` with exhaustive discriminated-union matching and business-rule enforcement (e.g., no overdrafts).
  • Implement `buildLedger` and `summariseLedger` — the latter must aggregate all fields in a single pass over the accounts Map.
challenge.ts
// Key types + main function signatures at a glance

export type EventId   = string & { readonly _brand: "EventId" };
export type AccountId = string & { readonly _brand: "AccountId" };

export type DomainEvent =
  | AccountOpened | MoneyDeposited | MoneyWithdrawn | AccountClosed;

export type Result<T, E> =
  | { ok: true;  value: T }
  | { ok: false; error: E };

export type AccountProjection = {
  accountId:  AccountId;
  ownerName:  string;
  balance:    number;
  status:     "open" | "closed";
  eventCount: number;
  openedAt:   string;
  closedAt:   string | null;
};

// Parse a raw unknown blob → typed DomainEvent or ValidationError
export function parseDomainEvent(raw: unknown): Result<DomainEvent, ValidationError>;

// Fold one event onto a projection (or null for first event)
export function applyEvent(
  projection: AccountProjection | null,
  event: DomainEvent
): Result<AccountProjection, ValidationError>;

// Process a whole stream, collect projections + errors
export function buildLedger(rawEvents: unknown[]): LedgerReport;

// Single-pass aggregate over all projections
export function summariseLedger(report: LedgerReport): LedgerSummary;
Hints (click to reveal)

Hints

  • For branded types, an intersection like `string & { readonly _brand: "EventId" }` lets you keep string assignability while blocking accidental mixing — use a helper cast only in your validators where you've already confirmed the raw value is a string.
  • In `applyEvent`, a `switch (event.type)` over the discriminated union gives you exhaustive narrowing for free — TypeScript will tell you if you miss a branch.
  • For the single-pass `summariseLedger`, use `Array.from(report.accounts.values()).reduce(...)` and accumulate all fields (open count, closed count, total balance, max balance, most-active ID) in one reducer accumulator object.

Or clone locally

git clone -b challenge/2026-04-11 https://github.com/niltonheck/typedrop.git