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