typescripttypestypescript-genericsunion-typesdiscriminated-union

How do you discriminate tagged unions when narrowing types using a type guard?


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?


Solution

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

    playground