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.
What version of Effect is running?
effect@4.0.0-beta.107
With TypeScript 6.0.3. The same signatures are on
maintoday (packages/effect/src/Match.tsat4.0.0-rc.110:tagsExhaustive,typeTags,orElse,orElseAbsurdall end in[Pr] extends [never] ? (input: I) => … : …), and ineffect@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(orexhaustive,orElseAbsurd,tagsExhaustive):What is the expected behavior?
Both
Matchversions typecheck. Every arm already does:Match.tag("Closed", …)is accepted (assignability intoTypes.Tags<"_tag", Closed | Open> & stringsucceeds through the concrete"Closed"member), andMatch.orElse((open) => …)receivesExclude<Open, Closed | Extract<Open, Record<"_tag", "Closed">>>, which is assignable toOpen. So the result of the pipeline should bestringandOption<Open>respectively.What do you see instead?
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:
PrisneverforMatch.type<T>()and the input type forMatch.value(x). Whenx's type mentions a type parameter (Closed | Openabove), 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 concreteClosedmember, 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 | Openfield, generic reducers, shared component plumbing. The only escapes are a cast on the result or droppingMatchfor a hand-written_tagguard, both of which cut against the library's own guidance to preferMatchfor tag dispatch.Proposal.
Matcheris already a union of two distinct interfaces,TypeMatcherandValueMatcher, 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]:With that,
Match.value(x)resolves straight to the result type regardless of whethertypeof xis generic, andMatch.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'sOpenkey 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_tagguard, as inreadOpenGuardabove.