Typeguard doesn't narrow type

101 views Asked by At

I'm creating an object to store a bunch of RGB colors, and nesting is allowed. So when looping through the object, I need to see which keys correspond to RGB values or to an object. However, every type guard I've tried won't actually narrow down the type.

type Color = [number, number, number] | 'transparent'
type ColorGroup = Record<string, Color>
type Colors = Record<string, Color | ColorGroup>

const colors: Colors = {
    black: [0, 0, 0],
    white: [255, 255, 255],
    transparent: 'transparent',
    primary: {
        '50': [211, 233, 252],
        '100': [179, 213, 248],
        '200': [127, 185, 251],
        '300': [68, 156, 253],
        '400': [0, 126, 254],
        '500': [13, 100, 226],
        '600': [17, 79, 189],
        '700': [15, 62, 157],
        '800': [10, 46, 122],
        '900': [1, 22, 77],
    }
}

const isColor = (color: Color | ColorGroup): color is Color => {
    return Array.isArray(color) || typeof color === 'string'
}

const usesColor = (color: Color):void => {
    // does something with the color
}

for(const color in colors) {
    if(isColor(colors[color])) usesColor(colors[color]) // error: type 'Record<string, Color>' is not assignable to type 'Color'
}

Playground link

Any ideas? Am I just missing something fundamental about type guards?

1

There are 1 answers

0
jcalz On BEST ANSWER

You've run into a design limitation in TypeScript. See microsoft/TypeScript#33391 and microsoft/TypeScript#31445 for more information.

The issue is that the compiler does not keep track of the results of property type guards unless those properties are a string literal or a number literal:

if (isColor(colors.black)) usesColor(colors.black); // okay

and not if it's a value stored in a variable:

if (isColor(colors[color])) usesColor(colors[color]) // error!

When accessing colors[color], the compiler only knows that color is a variable of type string. After the type guard, you access colors[color] again, but the compiler doesn't realize that you checked it before because color is just some string-typed variable to it. In some sense the compiler can't see the difference between your code and this:

declare const color1: string;
declare const color2: string;
if (isColor(colors[color1])) usesColor(colors[color2]); // error!

which would not be a good use of the type guard.

The above linked issues mention that while it would be nice if code like this were supported, it turns out to be very expensive in terms of compiler resources. Keeping track of which variables are used as indices is a lot of extra and almost always unnecessary work. The use case here is apparently not worth it... especially because:


There is a minor refactoring that gives the behavior you're looking for. Instead of doing multiple indexing operations, do a single indexing operation and save it into its own variable, like this:

for (const color in colors) {
    const c = colors[color];
    if (isColor(c)) usesColor(c) // okay
}

Since c is its own variable, there's no longer any indexing-with-string to worry about. The compiler can easily use the type guard on c to narrow the type of c. So, you get the behavior you want, at the expense of slightly less idiomatic JavaScript.

Playground link to code