Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
davezuch opened this issue Feb 7, 2024 · 3 comments
Open

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

davezuch opened this issue Feb 7, 2024 · 3 comments
Labels
enhancement New feature or request

Comments

@davezuch
Copy link

davezuch commented Feb 7, 2024

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 davezuch added the enhancement New feature or request label Feb 7, 2024
@davezuch
Copy link
Author

davezuch commented Feb 7, 2024

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
Copy link
Owner

gvergnaud commented Feb 8, 2024

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
Copy link
Author

davezuch commented Feb 21, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants