Skip to content

Match.value on an input whose type contains a type parameter yields an unresolved [Pr] extends [never] conditional, even when every arm typechecks #7315

Description

@devinjameson

What version of Effect is running?

effect@4.0.0-beta.107

With TypeScript 6.0.3. The same signatures are on main today (packages/effect/src/Match.ts at 4.0.0-rc.110: tagsExhaustive, typeTags, orElse, orElseAbsurd all end in [Pr] extends [never] ? (input: I) => … : …), and in effect@3, so I expect it reproduces on both.

What steps can reproduce the bug?

Write a generic helper over a tagged union where one member is a type parameter and finish the match with Match.orElse (or exhaustive, orElseAbsurd, tagsExhaustive):

import { Match, Option } from "effect"

type Closed = { readonly _tag: "Closed" }

export const describe = <Open extends { readonly _tag: "Open" }>(
  state: Closed | Open,
  describeOpen: (open: Open) => string,
): string =>
  Match.value(state).pipe(
    Match.tag("Closed", () => "closed"),
    Match.orElse((open) => describeOpen(open)),
  )

export const readOpen = <Open extends { readonly _tag: "Open" }>(
  state: Closed | Open,
): Option.Option<Open> =>
  Match.value(state).pipe(
    Match.tag("Closed", () => Option.none()),
    Match.orElse((open) => Option.some(open)),
  )

// Control: a plain guard over the same union resolves fine.
export const readOpenGuard = <Open extends { readonly _tag: "Open" }>(
  state: Closed | Open,
): Option.Option<Open> =>
  state._tag === "Closed" ? Option.none() : Option.some(state)

What is the expected behavior?

Both Match versions typecheck. Every arm already does: Match.tag("Closed", …) is accepted (assignability into Types.Tags<"_tag", Closed | Open> & string succeeds through the concrete "Closed" member), and Match.orElse((open) => …) receives Exclude<Open, Closed | Extract<Open, Record<"_tag", "Closed">>>, which is assignable to Open. So the result of the pipeline should be string and Option<Open> respectively.

What do you see instead?

error TS2322: Type '[Closed | Open] extends [never] ? (input: Closed | Open) => string : string'
  is not assignable to type 'string'.

error TS2322: Type '[Closed | Open] extends [never] ? (input: Closed | Open) => Option<Exclude<Open, Closed | Extract<Open, Record<"_tag", "Closed">>>> : Option<...>'
  is not assignable to type 'Option<Open>'.

The arms are fine; only the terminal combinator's return type is rejected.

Additional information

Cause. The terminal combinators use one conditional type to serve both
matcher flavours:

// packages/effect/src/Match.ts on main (same shape in v3)
export declare const orElse: <RA, Ret, F extends (_: RA) => Ret>(f: F) =>
  <I, R, A, Pr>(self: Matcher<I, R, RA, A, Pr, Ret>) =>
    [Pr] extends [never] ? (input: I) => Unify<ReturnType<F> | A> : Unify<ReturnType<F> | A>

Pr is never for Match.type<T>() and the input type for Match.value(x). When x's type mentions a type parameter (Closed | Open above), TypeScript defers [Pr] extends [never] instead of resolving it, so the whole match has a conditional type that is not assignable to anything concrete. [Closed | Open] can never be [never] because of the concrete Closed member, but TS does not reason about that for a check type containing a type variable.

Why this matters. Any generic helper over a tagged union hits it: lenses over a state: Closed | Open field, generic reducers, shared component plumbing. The only escapes are a cast on the result or dropping Match for a hand-written _tag guard, both of which cut against the library's own guidance to prefer Match for tag dispatch.

Proposal. Matcher is already a union of two distinct interfaces, TypeMatcher and ValueMatcher, each with its own _tag. The terminal combinators (orElse, orElseAbsurd, exhaustive, tagsExhaustive, typeTags, option, either, and friends) could be overloaded on those two interfaces instead of branching on [Pr] extends [never]:

export declare const orElse: <RA, Ret, F extends (_: RA) => Ret>(f: F) => {
  <I, R, A, Pr>(self: ValueMatcher<I, R, RA, A, Pr, Ret>): Unify<ReturnType<F> | A>
  <I, R, A>(self: TypeMatcher<I, R, RA, A, Ret>): (input: I) => Unify<ReturnType<F> | A>
}

With that, Match.value(x) resolves straight to the result type regardless of whether typeof x is generic, and Match.type<T>() still returns a function. Behaviour and inference for non-generic inputs would be unchanged.

Out of scope, for clarity. Match.tagsExhaustive({ Closed: …, Open: … }) on the same generic input fails for a different reason: Types.Tags<"_tag", R> is a distributive conditional, so the key set is "Closed" | <deferred> and the object literal's Open key cannot be checked. That one looks like a TypeScript limitation rather than something the library can fix; this issue is only about the terminal combinators' return type, which is library-side.

Workaround today. A refinement plus Option.liftPredicate, or a plain _tag guard, as in readOpenGuard above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions