1

I would like to discriminate a union type based on args that provided to a function, but for some reason I can't use a generic type for a shape of data. It brokes my narrowing. What do you think how can I achieve this?

export type DiscriminateUnionType<Map, Tag extends keyof Map, TagValue extends Map[Tag]> = Map extends Record<
  Tag,
  TagValue
>
  ? Map
  : never;

function inStateOfType<Map extends { [index in Tag]: TagValue }, Tag extends keyof Map, TagValue extends Map[Tag], DiscriminatedState extends DiscriminateUnionType<Map, Tag, TagValue>>(tag: Tag, value: TagValue, state: Map): DiscriminatedState | undefined {
  return state[tag] === value ? state as DiscriminatedState : undefined
}

type State = { type: 'loading', a: string } | { type: 'loaded', b: string } | { type: 'someOtherState', c: string }

export function main(state: State) {
  const loadedState = inStateOfType('type', 'loading', state)

  if (loadedState) {
    loadedState.b // Property 'b' does not exist on type 'State'. Property 'b' does not exist on type '{ type: "loading"; a: string; }'
  }
}

function inStateOfType<Map extends { type: string }, Tag extends 'type', TagValue extends Map[Tag], DiscriminatedState extends DiscriminateUnionType<Map, Tag, TagValue>>(state: Map, value: TagValue): DiscriminatedState | undefined {
  return state['type'] === value ? state as DiscriminatedState : undefined
}

function main(state: State) {
  // { type: "loaded"; b: string }, everything is fine, narrowing works
  // but in this case, inStateOfType function is not generic
  const loadedState = inStateOfType(state, 'loaded')

  if (loadedState) {
    loadedState.b 
  }
}

In order to investigate this I created an executable snippet with a code, so you can debug it on TS playground

arkhwise
  • 873
  • 9
  • 13
  • 1
    You don't want to constrain `Map extends { [index in Tag]: TagValue }` (btw, that `index` is a type parameter name so it should be in uppercase, and also, type parameter names are generally single characters, for better or worse, so I would call this `M extends {[P in K]: V}` or even `M extends Record`). That `Map` type is supposed to be the full union, so constraining the value to the type of `value` is going to break things. I'd widen that and hint to the compiler you want literal types, like [this](//tsplay.dev/WP76Ym). If that works for you I can write up an answer. Let me know. – jcalz Mar 22 '22 at 23:02
  • Seems, a code you proposed works perfectly fine for me! Thanks! What do you think is there any possibility to go without type assertion (as any)? @jcalz – arkhwise Mar 23 '22 at 08:28
  • Finally, I got [this](https://stackblitz.com/edit/typescript-umgdj9?file=index.ts) – arkhwise Mar 23 '22 at 16:01
  • I don't think you can completely avoid a type assertion, although you wouldn't need `as any`. You can do [this](https://tsplay.dev/wX73VW). Of course this sort of thing is usually done with a [user defined type guard function](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) like [this](https://tsplay.dev/wOJM7W). I could write up either or both in the answer; let me know. – jcalz Mar 23 '22 at 22:30
  • I would ask you to keep both. Let's write up the answer :) – arkhwise Mar 24 '22 at 17:52
  • Okay, I will do so when I get a chance. – jcalz Mar 25 '22 at 00:07

2 Answers2

1

You try to sort of do a reverse discrimination, which really isn't possible. This is because your TagValue extends Map[Tag], where Tag = type which unfortunately will always produce a union 'loaded' | 'loading' | 'someOtherValue'. When you extend Map[Tag] it is a sort of narrowing in of itself, so TS does not narrow it further, since 'loaded' will fulfill the union it'll pass there, but then goes on to pass on the whole union to the DiscriminatedState.

So when it tries to use TagValue in DiscriminateUnionType<>, it'll pass in the entire union and not really narrow it at all.

Instead of having TagValue extends Map[Tag] you should instead allow any string, then conditionally check it later. That way your narrowing occurs outside of the generic, where TS might infer and narrow it incorrectly.

function inStateOfGenericTypeFix<
  Map extends Record<string, any>, 
  Tag extends keyof Map, 
  TagValue extends string = Map[Tag], 
> (tag: Tag, value: TagValue, state: Map): 
  TagValue extends Map[Tag] 
  ? DiscriminateUnionType<Map, Tag, typeof value> 
  : unknown
  | undefined 
{
  return state[tag] === value ? state as any : undefined
}

  const loadedState = inStateOfGenericTypeFix('type', 'loaded', state)
  //  ^?

  if (loadedState) {
    loadedState.b //No more error!
  }

This unfortunately means you can pass in any string to the value, but if the string isn't a valid key, it will return unknown, basically letting you know the intended use was wrong. You can also configure this to return undefined or something else as you please.

Here is a lot of examples/the code on Playground

This has been my personal method of doing this sort of type inferencing, and if anyone else has a better method I'd love to hear it.

Cody Duong
  • 2,292
  • 4
  • 18
  • `This unfortunately means you can pass in any string to the value, but if the string isn't a valid key, it will return unknown, basically letting you know the intended use was wrong.` Yeah, that's the thing. Except this downside, what you've proposed looks like a possible and valid solution! Thanks for your contribution! – arkhwise Mar 23 '22 at 08:51
  • 1
    I also would like to recommend you to look at examples made by @jcalz https://stackoverflow.com/questions/71575047/how-can-i-narrow-a-union-type-based-on-a-key-value-and-a-shape-provided-to-a-fu/71578899#comment126509695_71575047 – arkhwise Mar 23 '22 at 08:55
1

In what follows I am going to change the names of your type parameters to be more in line with TypeScript conventions (single uppercase characters); Map will become M, Tag will become K (as it is a key of M), TagValue will become V, index will become I, and DiscriminatedState will become S. So now we have:

function inStateOfType<
  M extends { [I in K]: V },
  K extends keyof M,
  V extends M[K], 
  S extends Extract<M, Record<K, V>>
>(tag: K, value: V, state: M): S | undefined {
  return state[tag] === value ? state as S : undefined
}

And note that { [I in K]: V } is equivalent to Record<K, V> using the Record<K, V> utility type and that

type DiscriminateUnionType<M, K extends keyof M, V extends M[K]> =
  M extends Record<K, V> ? M : never;

can be dispensed with in favor of the built-in Extract<T, U> utility type as Extract<M, Record<K, V>>, so now we have:

function inStateOfType<
  M extends Record<K, V>,
  K extends keyof M,
  V extends M[K], S extends Extract<M, Record<K, V>>
>(tag: K, value: V, state: M): S | undefined {
  return state[tag] === value ? state as S : undefined
}

We're almost done cleaning this up to the point where we can answer. One more thing; the S type parameter is superfluous. There is no good inference site for it (no parameter is of type S or a function of S) so the compiler will just fall back to having S be exactly Extract<M, Record<K, V>>, meaning it's just a synonym for it.

And if you're going to write return xxx ? yyy as S : undefined then you don't need to annotate the return type at all, since it will be inferred as S | undefined.

So you could write the following and have everything work (or fail to work) the same:

function inStateOfType<
  M extends Record<K, V>,
  K extends keyof M,
  V extends M[K]
>(tag: K, value: V, state: M) {
  return state[tag] === value ?
    state as Extract<M, Record<K, V>> :
    undefined
}

So why doesn't that work? The big problem here is that M is supposed to be the full discriminated union type, so you can't constrain it to Record<K, V>, since V is just one of the various possible values for the key K. If you constrain M to Record<K, V>, then the compiler will not let you pass in a value for state unless it already knows that its tag property is the same type as value. Or, as in your case, the compiler will widen V so that it is the full set of possibilities for tag. Oops.

So if we can't constrain M to Record<K, V>, what should we constrain it to? It needs a key at K, but the value type there should only be constrained to be a viable discriminant property. Something like

type DiscriminantValues = string | number | boolean | null | undefined;

Let's try it:

function inStateOfGenericType<
  M extends Record<K, DiscriminantValues>,
  K extends keyof M,
  V extends M[K]
>(tag: K, value: V, state: M) {
  return state[tag] === value ?
    state as Extract<M, Record<K, V>> :
    undefined
}

function main(state: State) {
  const loadedState = inStateOfGenericType('type', 'loaded', state)

  if (loadedState) {
    loadedState.b // okay
  }
}

And that does it!


Do note that in TypeScript it is a little more conventional to rewrite this as a user defined type guard function where inStateOfType() returns a boolean that can be used to decide whether the compiler may narrow state to Record<K, V> or not:

function inStateOfGenericType<
  M extends Record<K, DiscriminantValues>,
  K extends keyof M,
  V extends M[K] 
>(tag: K, value: V, state: M):
  state is Extract<M, Record<K, V>> {
  return state[tag] === value
}

function main(state: State) {
  if (inStateOfGenericType('type', 'loaded', state)) {
    state.b // okay
  }
}

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360