fix: make GenomeEvent use template literal type for proper union narrowing#79
Conversation
…owing Fixes openseed-dev#78. The catch-all `{ type: string }` in GenomeEvent defeated TypeScript's discriminated union narrowing on the Event type. This caused all narrowing checks on event.type to be unreliable, requiring `as any` casts throughout index.ts. Changes: - GenomeEvent.type is now ``genome.${string}`` template literal, ensuring it doesn't overlap with host.* or creature.* types - Added missing event types to HostEvent: budget.exceeded, budget.reset, narrator.entry - Added missing event types to CreatureLifecycleEvent: creature.spawning, creature.spawned, creature.spawn_failed - Added janeeVersion to creature.boot type definition - Removed all 5 `as any` casts from index.ts that were symptoms of the broken union narrowing
There was a problem hiding this comment.
Reviewed the diff carefully and compiled on the branch — clean build.
This is the right fix. The genome.${string} template literal creates a clean namespace boundary that lets TypeScript's discriminated union narrowing actually work. The result: all 5 as any casts gone, and lines 373 and 437 (the ones I flagged in #78 as the remaining hard cases) are fixed too, not just the emit sites.
A few things I checked:
narrator.entry shape — the type definition { text: string; blocks?: Record<string, string>; creatures_mentioned: string[] } matches exactly what narrator.ts emits at line 386–390. No mismatch.
creature.request_restart narrowing — previously (event as any).reason on line 373. Now event.reason works because with GenomeEvent excluded from the creature.* namespace, the guard event.type === 'creature.request_restart' correctly narrows to the CreatureLifecycleEvent variant that has reason: string.
creature.boot narrowing — same fix applies to line 437, event.janeeVersion now resolves cleanly.
Migration note — the PR description correctly calls this out: any genome emitting events without a genome. prefix will break at the type level. That's the right tradeoff. If a genome event type doesn't start with genome., it's using a reserved namespace it shouldn't be touching anyway.
Overlap with PR #77 — #79 is a strict superset of #77. Recommend closing #77 in favour of this one to avoid a rebase conflict.
One minor observation: narrator.entry is added to HostEvent rather than its own category. That's fine — it's emitted by the host process. But if the narrator grows more event types later, it might be worth a NarratorEvent subtype. Not worth doing now.
Ship it.
Fixes #78.
Problem
GenomeEvent = { t: string; type: string; [key: string]: unknown }is a catch-all that absorbs all other members of theEventdiscriminated union. TypeScript can't narrowevent.type === 'creature.boot'becauseGenomeEventalso matches — forcingas anycasts on every property access after narrowing.Solution
Option A from #78: Make
GenomeEvent.typea template literalgenome.${string}.This creates a clean namespace boundary:
host.*andcreature.*events have literal type strings → TypeScript narrows them perfectlygenome.*events are genome-specific and don't overlapAdditional changes
as anyto emit:HostEvent:budget.exceeded,budget.reset,narrator.entryCreatureLifecycleEvent:creature.spawning,creature.spawned,creature.spawn_failedjaneeVersiontocreature.boottype (was accessed viaas anycast)as anycasts fromindex.ts— they're no longer needed since narrowing works correctlyMigration note
Any genome that emits events with types not prefixed
genome.will need to update their event type strings. The host and creature lifecycle events are all accounted for in the updated types.