Skip to content

Multi-constructor data types break cross-module inference for callers using bound vars #60

Description

@kumavis

Summary

Adding a SECOND constructor to a data type in module M1 causes importing module M2 to fail elaboration with "Could not infer type" on multi-arg helpers that call a function returning that type — even though the same code works inside M1 itself, even though every relevant spec is concrete, and even with explicit type annotations on let bindings.

The defining module compiles fine. The failure is on the importing-module side, where 2+ bound function-arg variables cannot have their types propagated through a call that returns the multi-constructor type. Function-arg vars come back as <_> (uninferred metavars) in the elaborated form.

The full pitfall write-up is in docs/tracking/2026-04-27_GOBLIN_PITFALLS.md entry #32.

Repro skeleton

Inside module M1:

data Step
  step     : Vat -> State                  ;; ctor 1
  step-out : Vat -> State -> List String   ;; ctor 2

spec do-step Op Vat State -> Step
defn do-step | ... -> step v st           ;; or step-out v st bytes

Inside an importing module M2:

require [M1 :refer-all]

;; FAILS — "Could not infer type"
spec wrap Op Vat State -> Vat
defn wrap [op v st]
  let s := [do-step op v st]
    [step-vat s]

Real-world failure

Discovered in OCapN Phase 25 (handshake modelling, commit 4b92416). I extended BridgeStep with a second constructor bridge-step-out carrying immediate-outbound bytes:

data BridgeStep
  bridge-step     : Vat -> BridgeState                   ;; existing
  bridge-step-out : Vat -> BridgeState -> [List String]  ;; NEW

The defining module captp-bridge.prologos continued to compile cleanly — connection-step (which uses bridge-step-vat, bridge-step-state, bridge-step-immediate) was fine.

But the importing module bridge-interop-helpers.prologos failed to define EVERY multi-arg helper that called captp-incoming-with-state and unpacked the result. Failures persisted under:

  • 1 let, 2 let, 3+ let chains
  • Inline (no let)
  • Explicit type annotation on the let binding (let s : BridgeStep := ...)
  • Multi-arity defn syntax (| op v st ->) instead of bracket syntax ([op v st])
  • Both bracket and bare-juxtaposition body shapes

Probe matrix

The probe matrix narrowed the trigger:

function shape defines?
1-arg + let + captp-call (constants for other args) yes
2-arg + let + captp-call (1 bound + 1 constant) NO
3-arg + let + captp-call (3 bound) NO
2-arg + INLINE + captp-call (all bound) NO
Same shape, but BridgeStep has only 1 constructor yes

So the trigger is: multi-constructor data type as the return type of an imported function whose call site has 2+ bound metavar args.

Hypothesis

The elaborator's bidirectional inference for function applications can't pin down the return type of a function returning a multi-constructor data type when the call's arguments include 2+ unsolved metavariables (function-arg vars whose types haven't been propagated from the spec yet). With a single-constructor return type, the constructor's signature uniquely determines the type, but with N constructors the elaborator may delay the choice in a way that fails to back-propagate through the let.

Workaround

Avoid multi-constructor data types when the constructor's primary use is "value with optional extra payload."

  1. Single constructor with all fields. Add a [List X] or [Option X] field that's nil / none for the bare case. Trade-off: every accessor and update function gets an extra wildcard, but it's mechanical. This is what Phase 25 ended up using — pending-out : List String was added to BridgeState (single-constructor) instead of a second bridge-step-out constructor on BridgeStep.

  2. Wrap the optional payload in a separate function. Instead of "constructor with X" + "constructor without X," have a single constructor + a separate accumulator (e.g. a list field or queue) that the producing function appends to.

The single-ctor workaround is uniformly applicable and clean. But it's a real expressivity gap — multi-constructor data types are fundamental.

Why filing this is worth it

  1. Catastrophic failure mode: importing modules fail to compile. The error doesn't point to the data def — it points to the call site.
  2. Far from surface cause: the data type definition is in a DIFFERENT module from where the failure surfaces. Diagnosis took ~1 hour of probing with shrinking test cases.
  3. Workaround is invasive: moves what should be optional payload onto the parent type, with field plumbing through every accessor.
  4. Fundamental expressivity gap: until fixed, the language can't easily express "this dispatch returns a value-with-or-without-extra-output" patterns naturally.

Cross-references

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions