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);
Mapwill becomeM,Tagwill becomeK(as it is a key ofM),TagValuewill becomeV,indexwill becomeI, andDiscriminatedStatewill 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
Stype parameter is superfluous. There is no good inference site for it (no parameter is of typeSor a function ofS) so the compiler will just fall back to havingSbe 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 : undefinedthen 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
Mis supposed to be the full discriminated union type, so you can't constrain it toRecord<K, V>, sinceVis just one of the various possible values for the keyK. If you constrainMtoRecord<K, V>, then the compiler will not let you pass in a value forstateunless it already knows that itstagproperty is the same type asvalue. Or, as in your case, the compiler will widenVso that it is the full set of possibilities fortag. Oops.So if we can't constrain
MtoRecord<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 abooleanthat can be used to decide whether the compiler may narrowstatetoRecord<K, V>or not:Playground link to code