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"
}