Here's a simple example:
type Foo = {
1: {
name: 'bobby';
};
2: {
name: 'mary';
};
};
type Mapped<T> = {
[K in keyof T]: T[K] & {
getStatus(): K;
status: K;
};
}[keyof T];
type MappedFoo = Mapped<Foo>;
const foo = (value: MappedFoo) => {
if(value.getStatus() === 1) {
return value.name
}
if(value.status === 1) {
return value.name
}
return
}
The issue is that when checking for status
property the type of name
property inside of the if's body correctly narrows down to 'bobby'
. However when using getStatus()
function on the type, the type narrowing does not happen and the type of name
property is still 'bobby' | 'mary'
.
It's a missing feature of TypeScript. Right now discriminated unions only support discriminant properties of "unit types" like string/number literal types, or undefined
, or null
. They do not support anything else, such as methods whose return types are such literals. There's an open feature request for this at microsoft/TypeScript#49771. It currently has very few upvotes and has the status "Awaiting More Feedback", meaning they need to hear from a lot more people why it would be helpful. If you want to see this implemented, it wouldn't hurt for you to go give it a 👍 and describe your use case and why it's compelling. Of course it probably wouldn't help much either.
So you can't do this directly. You can sort of do it indirectly by writing a custom type guard function to describe the sort of narrowing you'd like to see. For example, let's say we have an object obj
with a zero-arg method at key K
, and that when we check that it returns a value whose type is T
, we can assume that the object is of type Record<K, ()=>T>
and use that to narrow the type of obj
. Then that leads to a custom type guard function like:
function methodReturns<T extends string | number | undefined | null, K extends PropertyKey>(
obj: Record<K, () => unknown>, k: K, v: T
): obj is Record<K, () => T> {
return obj[k]() === v
}
And you can see it work:
const foo = (value: MappedFoo) => {
if (methodReturns(value, "getStatus", 1)) {
return value.name
// ^? (property) name: "bobby"
} else {
return value.name
// ^? (property) name: "mary"
}
}
Of course you might have different use cases that suggest a different type guard function... maybe you have a method with arguments, or maybe you have other sorts of nested unions, such as would be supported with microsoft/TypeScript#18758, or maybe you have objects you want to narrow by narrowing properties, as would be supported with microsoft/TypeScript#42384. You could write a slew of type guard functions and use them where appropriate. That might look like:
function discrim<T extends object, K extends keyof T, V extends T[K]>(
obj: T, k: K, guard: (o: T[K]) => o is V):
obj is Extract<T, Record<K, V>> {
return guard(obj[k])
}
function methodDiscrim<T extends Record<K, Function>, K extends keyof T, V extends T[K]>(
obj: T, k: K, guard: (o: T[K]) => o is V): obj is Extract<T, Record<K, V>> {
return guard(obj[k].bind(obj))
}
function functionGuard<A extends any[], R, S extends R>(
f: (...a: A) => R,
guard: (r: R) => r is S,
...a: A): f is (...a: A) => S {
return guard(f(...a))
}
and then
const foo = (value: MappedFoo) => {
if (methodDiscrim(value, "getStatus", o => functionGuard(o, r => r === 1))) {
value.name
// ^? (property) name: "bobby"
} else {
value.name
// ^? (property) name: "mary"
}
but we're really getting ahead of ourselves there. The point is just that you can either wait for TypeScript to implement such things natively, or work around them by supplying your own type guard functions.