Skip to content

genes-ts: preserve payload reads after erased enum matches - #104

Merged
fullofcaffeine merged 2 commits into
mainfrom
fix/enum-payload-narrowing
Jul 30, 2026
Merged

genes-ts: preserve payload reads after erased enum matches#104
fullofcaffeine merged 2 commits into
mainfrom
fix/enum-payload-narrowing

Conversation

@fullofcaffeine

@fullofcaffeine fullofcaffeine commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Why

A Haxe program can safely match a generic enum even when TypeScript cannot reconstruct the same proof from the generated union.

This surfaced while compiling GameCarry's package-neutral tink_cli integration fixture. The Tink source contains the equivalent of:

static function all(
    source:IdealSource
):Future<Chunk> {
  return Source.concatAll(source).map(function (outcome) {
    return switch outcome {
      case Reduced(chunk): chunk;
    };
  });
}

Reduction<Item, Safety, Quality, Result> has failure constructors that fix Safety or Quality to an error type. In this call, both parameters are a nominal Noise type, so those constructors cannot produce the applied enum value. Haxe proves that only Reduced is possible, accepts the one-case match, and may erase the runtime switch.

The final typed Haxe tree still contains an exact payload-read node: it names the Reduced constructor, payload slot 0, the fully applied receiver enum, and the exact result type. Before this change, Genes emitted the remaining read directly:

// TypeScript still sees the complete Reduction union.
return outcome.result;

Strict TypeScript reports TS2339: result does not exist on the other union variants. This is not unsafe Haxe source and it is not a framework-specific type rule; it is a difference between Haxe's completed enum proof and the information visible to TypeScript after that proof has been erased.

What

This PR adds one narrow TypeScript boundary decision for an exact enum payload read left behind by an erased Haxe match.

The generated TypeScript now preserves Haxe's already-proven constructor view:

return Register.unsafeCast<
  Reduction.Reduced<ChunkObject, Noise, Noise, ChunkObject>
>(outcome).result;

Register.unsafeCast<T>(value) is a runtime identity operation. It evaluates value once and returns that same value. It does not convert, validate, repair, clone, or otherwise change the data. Its type argument records for TypeScript the exact constructor fact that Haxe already proved.

When Haxe keeps an ordinary discriminant switch, Genes continues to emit idiomatic TypeScript with no assertion:

switch (outcome._hx_index) {
  case 2: {
    const chunk = outcome.result;
    return chunk;
  }
}

Classic JavaScript remains the same direct property read:

return outcome.result;

The focused fixture's complete classic JavaScript and declaration artifacts were byte-identical to unmodified main. Its index.js SHA-256 was bbfe700cc37290a991365b7a764a31573386cc7234df3fbf4f5a6f6771161800 in both builds. Source-map files differed only because the comparison used different output-directory paths.

How

The immutable TsBoundaryPlan now records an EnumPayloadRead decision before dependency bindings and import aliases are allocated. The emitter only consumes that precomputed decision; it does not infer a cast while printing TypeScript.

A decision is authorized only when the final typed AST proves all of the following:

  • the expression is Haxe's exact TEnumParameter payload-read node;
  • the receiver has a resolved, fully applied enum type;
  • the constructor's checked return type authenticates the same enum declaration;
  • the constructor name and numeric index match that declaration;
  • the payload slot exists;
  • applying the enum parameters produces exactly the payload type recorded on the expression; and
  • no visible _hx_index switch arm already gives TypeScript the same narrowing fact.

Exact enum declarations are correlated through Haxe's compiler-owned fully-qualified module/type coordinates. When Haxe re-encodes one logical type parameter through multiple wrapper objects, the shared exact-type comparison may use its already-documented request-local module/name plus source-range fallback. This fallback stays inside one immutable module plan and is not a general source-position identity rule.

The planner does not infer authority from generated property text, unqualified names, TypeScript diagnostics, raw target strings, or downstream framework names. Every type that the assertion can print is exposed to dependency collection before import projection. This prevents late import-binding discovery.

The planner fails closed for Dynamic, unresolved monomorphs, invalid payload slots, unrelated same-named constructors, and constructor-local generic applications. These cases receive no assertion and remain visible to strict TypeScript rather than being guessed through.

Focused regression fixture

tests/enum-payload-narrowing is dependency-free and includes:

  • a one-case generic enum match that Haxe proves and erases;
  • a normal three-case match that must keep native TypeScript narrowing;
  • typed-tree positive evidence and negative controls;
  • explicit negative probes for Dynamic, an unresolved monomorph, an invalid slot, a constructor-local generic, and an unrelated same-named enum;
  • an assertion-only marker type that proves its type import is planned before binding allocation;
  • a direct Factory.read() receiver that proves single evaluation;
  • TypeScript and standard-Haxe-JavaScript runtime transcript parity;
  • classic-output assertions; and
  • source-map provenance for the generated wrapper.

The same Haxe → failing TypeScript → planned TypeScript example, proof boundary, limitations, and import timing are documented in docs/ARCHITECTURE.md, and the compiler fixture guide routes future changes to this test.

Run the focused task with:

yarn test:enum-payload-narrowing

Downstream evidence

Against GameCarry's pinned package-neutral tink_cli fixture and official Haxe 4.3.7, this branch changes the strict TypeScript diagnostic count from exactly 16 to 15. The only removed diagnostic is the expected TS2339 in tink/io/Source.ts; the other 15 diagnostics are unchanged.

The fixture was then regenerated with GameCarry's authoritative merged Genes pin, restoring the documented baseline of 16. GameCarry will not pin this branch: it will move through Lix only after this PR is independently reviewed and merged.

The two separate Bytes.toHex diagnostics remain intentionally visible. Their missing typed boundary belongs to the Haxe standard library and is tracked in HaxeFoundation/haxe#13003 and HaxeFoundation/haxe#13004; this PR does not add a raw-syntax inference rule.

Validation

Observed locally on the implementation commit:

  • yarn test:enum-payload-narrowing
  • yarn test:higher-order-enum-constructors
  • yarn test:classic:dts
  • yarn test:genes-ts:full — 350/350 assertions in the acceptance profile
  • core full profile — 351/351 assertions
  • yarn test:compiler-server — cold/warm requests, edits, rollback, and cleanup
  • yarn test:output-quality
  • yarn benchmark:dependency-plan
  • yarn test:acceptance — passed in 1,013.98 seconds, including both TodoApp Playwright profiles
  • yarn test:ci — passed in 1,175.45 seconds
  • yarn precommit:run — formatting and staged secret scan passed

After independent review requested the missing negative evidence and architecture documentation, exact head def4499 also passed:

  • yarn test:enum-payload-narrowing after staged formatting — includes the new unresolved-monomorph and constructor-local-generic negatives across TypeScript 5/6/7, runtime, classic output, and source maps
  • yarn test:agent-guides
  • yarn precommit:run

GitHub's protected CI matrix reruns on the exact new head before merge.

Report-only output performance remained in the established range: TypeScript approximately 2.0–2.1 seconds and classic approximately 1.9–2.0 seconds for the measured fixture. The dependency-plan benchmark completed at 128, 256, and 512 edge sizes; the largest/smallest ratio was 5.11× for 4× the edges.

Independent review disposition

The first independent Genes review found no implementation correctness defect, but requested three completeness changes. Exact head def4499 now:

  1. adds the Bead-required unresolved-monomorph negative and a constructor-local-generic negative;
  2. documents the new boundary in the authoritative architecture and fixture guides; and
  3. replaces an overbroad “no package names/source positions” claim with the exact compiler-owned coordinate and bounded type-parameter fallback contract.

The second independent review approved exact head def4499 with no remaining actionable finding. The PR is ready for review; merge remains gated on required CI and a final conversation/thread check.

Limits

This is not a blanket assertion for enum payload reads and it is not a TypeScript assignability engine. It intentionally does not authorize projections from Dynamic, Any, unresolved monomorphs, copied constructor names, diagnostic text, raw JavaScript strings, or constructor-local generic applications whose exact parameters are absent from the receiver.

Unsupported shapes continue to fail strict TypeScript so they can be modeled deliberately instead of being hidden by a broad cast.

Owning Bead: genes-pxkv.14.

Prepared by the GameCarry agent.

Haxe can prove that only one generic enum constructor can inhabit a value and erase the authored switch. TypeScript still sees the complete emitted union, so the remaining direct payload read fails strict checking.

Plan an exact constructor view from the final typed AST before imports are allocated, then render one identity projection only when no emitted discriminant switch already gives TypeScript the same fact. Ordinary switches remain direct and classic JavaScript remains unchanged.

Add a dependency-free focused fixture covering typed evidence, fail-closed controls, assertion-only imports, single evaluation, TypeScript 5/6/7, runtime parity, classic output, and source maps. Full acceptance and yarn test:ci pass; the pinned tink_cli pressure test removes only the Source.ts TS2339 diagnostic (16 to 15 on Haxe 4.3.7).

Prepared by the GameCarry agent.
Add executable fail-closed controls for an unresolved monomorph receiver and a constructor-local generic application, matching the scope promised by the owning Bead and PR.

Document the erased-match boundary in the authoritative architecture and fixture guides with Haxe, failing TypeScript, planned TypeScript, import timing, and unsupported cases. Describe declaration correlation accurately as compiler-owned module/type coordinates with the existing request-local type-parameter source-range fallback.

The focused TypeScript 5/6/7, runtime, classic, and source-map gate passes after formatting, as do the agent-guide and staged pre-commit checks.

Prepared by the GameCarry agent.
@fullofcaffeine
fullofcaffeine marked this pull request as ready for review July 30, 2026 02:22
@fullofcaffeine
fullofcaffeine merged commit 6912bf6 into main Jul 30, 2026
15 checks passed
@fullofcaffeine
fullofcaffeine deleted the fix/enum-payload-narrowing branch July 30, 2026 02:50
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.

1 participant