Open davezuch opened 10 months ago
Well right after writing that all up I found a solution, by using type guards:
type Example<Value> = Bar<Value> | Foo<Value>
type Bar<Value> = { bar: Value }
type Foo<Value> = { foo: Value }
const isBar = <Value>(e: Example<Value>): e is Bar<Value> => 'bar' in e
const isFoo = <Value>(e: Example<Value>): e is Foo<Value> => 'foo' in e
const getValue = <Value>(e: Example<Value>): Value =>
match(e)
.when(isBar, ({ bar }) => bar as Value)
.when(isFoo, ({ foo }) => foo as Value)
.exhaustive()
The problem is those type guards aren't type-safe. If one of the keys are mistyped, or if one of the keys in the types change, or if one of the types are updated so that there's overlap, the compiler won't notice. Not sure how to fix that.
The issue you are facing will only come up if you are matching on an unknown type parameter (Value
in your example). TS-Pattern will behave correctly if you instantiate Value
with a concrete type: Playground
import { match, P } from 'ts-pattern'
type Example<Value> =
| { foo: Value }
| { bar: Value }
// We instantiate `Example` with the `number` type.
// 👇
const getValue = (e: Example<number>): number => match(e)
.with({ foo: P.any }, ({ foo }) => foo) // ✅ works
.with({ bar: P.any }, ({ bar }) => bar) // ✅ works
.exhaustive()
The reason why is that TypeScript's type inference gets stuck on unknown type parameter. The type-level algorithm that ts-pattern uses to narrow the input type can't complete because expressions like Value extends number ? true : false
don't reduce to either true
or false
because TS doesn't know if Value
is assignable to number
or not.
the fact that TS-Pattern doesn't support generic types is a known limitation, but it's really a limitation of the language unfortunately.
My recommendation here is to use function signature overloads separate the public facing API of your function from the types it uses internally:
function getValue<T>(e: Example<T>): T;
function getValue(e: Example<unknown>): unknown {
return match(e)
.with({ foo: P.any }, ({ foo }) => foo)
.with({ bar: P.any }, ({ bar }) => bar)
.exhaustive()
}
I wrote about the reason I think this is the best option in this blog post: https://type-level-typescript.com/articles/making-generic-functions-pass-type-checking
My recommendation here is to use function signature overloads separate the public facing API of your function from the types it uses internally:
function getValue<T>(e: Example<T>): T; function getValue(e: Example<unknown>): unknown { return match(e) .with({ foo: P.any }, ({ foo }) => foo) .with({ bar: P.any }, ({ bar }) => bar) .exhaustive() }
I wrote about the reason I think this is the best option in this blog post: https://type-level-typescript.com/articles/making-generic-functions-pass-type-checking
Thanks for the suggestion! That's a better solution than the type guards I came up with.
Not sure if you prefer to close this or leave it open in case others come looking the same issue.
Is your feature request related to a problem? Please describe.
Say I have a union of two object types holding an unknown
Value
in different keys:I'd like to be able to match on either case with something like:
This gives the following TS errors respectively, on each destructure:
I'm not sure why it thinks the matched type could be
{}
. I suppose sinceValue
is unknown it could beundefined
. I assume that's also why replacingP.any
with either ofP.not(undefined)
orP.not(P.nullish)
doesn't work either.Describe the solution you'd like
I feel we could side-step the concern of whether
Value
extendsundefined
by only checking whether the key exists, since these two are not equivalent to the compiler:If there were something like a
keyExists
pattern, the above could become:Describe alternatives you've considered
I've also tried adding a constraint on the type to make sure it's not
undefined
with:In addition to that, I've tried checking that the key isn't there with something like:
The logic being that if
foo
is optional or undefined,bar
must exist, and vice versa. Didn't work either.