Consider a helper function
const isKeyOf = <K extends PropertyKey, O extends object>(key: K, object: O): key is K & keyof O => key in object
Here, isKeyOf narrows the key type when checking if a key exists in an object
const obj = {
a: 1,
b: 2,
c: 3
}
const key: string = "a" // type of key is string
if (isKeyOf(key, obj)) {
// type of key is "a" | "b" | "c"
}
This works fine as long as obj is not a union of types, however:
const holder: Record<number, { a: number } | { b: number }> = {
1: { a: 1 },
2: { b: 2 }
}
// type of obj is { a: number } | { b: number }
const obj = holder[1 as number]
const key: string = "a" // type of key is string
if (isKeyOf(key, obj)) {
// type of key is never!
// However, this branch does execute, and
console.log(typeof key) // prints string
console.log(obj) // prints { a: 1 }
}
After some investigation, I figured this is because the keyof operator only lists keys that are common to all types in a union
type objType = { a: number } | { b: number }
type key = keyof objType // = never
yet, in this case I've checked that key is indeed in objType in the type guard. How do I write a type guard that correctly checks this and assigns the correct type to key?
As you mentioned, the keyof operator will return only the common keys, thus the following type will result in never:
// never
type Result = keyof ({ a: string } | { b: string });
To fix it we need to check keyof for every member of the union separately. So our end result should look like this:
// "a" | "b"
type Result = keyof { a: string } | keyof { b: string };
We can achieve this by using distributive conditional types. Union types are distributed whenever they are checked against some condition using extends. Example:
type Test<T> = T extends number ? T : never;
// 1
type Result = Test<'a' | false | [] | 1>
To make sure that we don't lose any members like in the previous example, we need to have a condition that will be always true. Possible conditions are to check against any or the T itself, since T extends T is always true:
type Test<T> = T extends T ? keyof T : never;
// "a" | "b"
type Result = Test<{ a: string } | { b: string }>
Looks good! Let's adapt it to your type guard:
const isKeyOf = <K extends PropertyKey, O extends object>(
key: K,
object: O,
): key is K & (O extends O ? keyof O : never) => key in object;
Testing:
const obj = holder[1 as number];
const key: string = 'a'; // type of key is string
if (isKeyOf(key, obj)) {
key; // "a" | "b"
}