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
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 becomeM
,Tag
will becomeK
(as it is a key ofM
),TagValue
will becomeV
,index
will becomeI
, andDiscriminatedState
will becomeS
. So now we have:And note that
{ [I in K]: V }
is equivalent toRecord<K, V>
using theRecord<K, V>
utility type and thatcan be dispensed with in favor of the built-in
Extract<T, U>
utility type asExtract<M, Record<K, V>>
, so now we have: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 typeS
or a function ofS
) so the compiler will just fall back to havingS
be exactlyExtract<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 asS | undefined
.So you could write the following and have everything work (or fail to work) the same:
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 toRecord<K, V>
, sinceV
is just one of the various possible values for the keyK
. If you constrainM
toRecord<K, V>
, then the compiler will not let you pass in a value forstate
unless it already knows that itstag
property is the same type asvalue
. Or, as in your case, the compiler will widenV
so that it is the full set of possibilities fortag
. Oops.So if we can't constrain
M
toRecord<K, V>
, what should we constrain it to? It needs a key atK
, but the value type there should only be constrained to be a viable discriminant property. Something likeLet's try it:
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 aboolean
that can be used to decide whether the compiler may narrowstate
toRecord<K, V>
or not:Playground link to code