gvergnaud / ts-pattern

🎨 The exhaustive Pattern Matching library for TypeScript, with smart type inference.
MIT License
11.87k stars 123 forks source link

How to check if a key of unknown type exists on an object? #222

Open davezuch opened 7 months ago

davezuch commented 7 months ago

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:

type Example<Value> =
  | { foo: Value }
  | { bar: Value }

I'd like to be able to match on either case with something like:

const getValue = <Value>(e: Example<Value>): Value => match(e)
  .with({ foo: P.any }, ({ foo }) => foo)
  .with({ bar: P.any }, ({ bar }) => bar)
  .exhaustive()

This gives the following TS errors respectively, on each destructure:

Property 'foo' does not exist on type '{} | { foo: Value; }'.
Property 'bar' does not exist on type '{} | { bar: Value; }'.

I'm not sure why it thinks the matched type could be {}. I suppose since Value is unknown it could be undefined. I assume that's also why replacing P.any with either of P.not(undefined) or P.not(P.nullish) doesn't work either.

Describe the solution you'd like

I feel we could side-step the concern of whether Value extends undefined by only checking whether the key exists, since these two are not equivalent to the compiler:

{ foo: Value | undefined }
{ foo?: Value }

If there were something like a keyExists pattern, the above could become:

const getValue = <Value>(e: Example<Value>): Value => match(e)
  .with({ foo: P.keyExists }, ({ foo }) => foo)
  .with({ bar: P.keyExists }, ({ bar }) => bar)
  .exhaustive()

Describe alternatives you've considered

I've also tried adding a constraint on the type to make sure it's not undefined with:

type Defined<Value> = Value extends undefined ? never : Value

type Example<Value> =
  | { foo: Defined<Value> }
  | { bar: Defined<Value> }

In addition to that, I've tried checking that the key isn't there with something like:

type Example<Value> =
  | { foo: Defined<Value>, bar?: undefined }
  | { bar: Defined<Value>, foo?: undefined }

const getValue = <Value>(e: Example<Value>): Value => match(e)
  .with({ foo: P.optional(undefined) }, ({ bar }) => bar)
  .with({ bar: P.optional(undefined) }, ({ foo }) => foo)
  .exhaustive()

The logic being that if foo is optional or undefined, bar must exist, and vice versa. Didn't work either.

davezuch commented 7 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.

gvergnaud commented 7 months ago

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

davezuch commented 7 months ago

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.