Skip to content

Confusing TYPE MISMATCH when an infallible function's closed-empty error union meets a fatter annotation #7

Description

@ESRogs

Summary

When a function's error union solves to closed empty — every fallible call inside it is handled with match, so it infers Try(x, []) — and that function is the tail expression of a caller whose annotated error union is fatter, the TYPE MISMATCH diagnostic is very hard to act on:

  • it highlights the caller's entire body (~40 lines in the real case) rather than the tail call that fails to unify;
  • it prints Try({}, []) with no explanation that [] is a closed empty union, or that closedness is what blocks unification with the fatter union;
  • it gives no hint toward either fix (call the infallible function with ? / return Ok(...) explicitly, or stop returning Try from the infallible function at all).

The behavior appears correct under the rigid-extension-variable semantics — this issue is only about diagnostic quality.

Real-world error

From a ~500-line app (main! annotated Try({}, _); sweep_loop! annotated Try({}, _) whose body's errors are all match-handled, called as main!'s tail expression):

┌───────────────┐
│ TYPE MISMATCH ├─ This expression is used in an unexpected way. ─────────────┐
└┬──────────────┘                                                             │
 │                                                                            │
 │  main! = |_args| {                                                         │
 │      ... (the diagnostic reproduces main!'s entire ~40-line body) ...      │
 │  }                                                                         │
 │                                                                            │
 └──────────────────────────────────────────────────────── main.roc:75:17 ┘

    It has the type:

        Try({}, [])

    But the annotation says it should be:

        Try({}, [Exit(d), MissingEnv(Str),
        ReadTeammatesFailed([NoSuchField(Str), SqliteErr(Sqlite.ErrCode, Str),
        UnexpectedType([Bytes, Integer, Null, Real, String, ..]), ..]),
        StderrErr(IOErr), ..])
          where [d.from_numeral : Numeral -> Try(d, [InvalidNumeral(Str)])]

Working out that the story was "sweep_loop!'s union solved to closed [], and a closed union can't widen into the annotation's union at the tail position" took substantial digging; the eventual fix was one line (sweep_loop!(...)? followed by Ok({}) — and ultimately dropping Try from the infallible function's signature entirely).

Repro

Full reproduction (roc release-fast-c9147c28):

  1. Check out ESRogs/zulr at commit 69a9217 (needs the basic-cli clone the app's platform path points at)
  2. In roc/dispatcher/main.roc, replace the two lines sweep_loop!({ bots, statuses, cooldowns: [], sweep: 1 })? / Ok({}) with the single tail expression sweep_loop!({ bots, statuses, cooldowns: [], sweep: 1 })
  3. roc check roc/dispatcher/main.roc → the error above

Note on minimization: small reproductions of the apparent shape do not reproduce it — a self-recursive effectful f : U64 => Try({}, _) with all errors handled, called as the tail of an annotated main!, checks cleanly (the union stays flexible and widens fine), as does a chain of two _-annotated effectful functions joined by ?. Whatever closes the union to [] needs more context than those shapes — which may itself be a useful clue.

Suggested improvements

Any of these would have shortened the investigation from hours to seconds:

  1. Anchor the highlight on the tail expression whose type failed to unify, not the whole function body.
  2. When the mismatched side is a closed union, say so: "this union is closed (it lists every tag it can be), so it cannot pick up the extra tags the annotation allows."
  3. When the closed union is empty, consider a dedicated hint: "this expression can never fail; if the surrounding function is annotated to return errors, consider expr? followed by Ok(...), or removing Try from the callee's type."

Staged in this fork for review before filing upstream (roc-lang/roc).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions