typescripttypescript-genericsunion-typestypeguardstype-narrowing

TypeScript TypeGuard Issue with Generic Class and Union Types


I have a generic Attributes class designed to manage stats, and I want to use a type guard to narrow down the type. Here’s the some of the code, more in the playground:

class Attributes<T extends Record<string, Attribute>> {
  #attributes: T;

  constructor(initial: T) {
    this.#attributes = structuredClone(initial);
  }

  hasStat(name: string): name is keyof T{
    return name in this.#attributes;
  } 

  ...
}

I’m using the hasStat method to check if a specific attribute exists:

const foo = new Attributes({ foo: { value: 1, min: 0, max: 5 } })
const bar = new Attributes({ bar: { value: 1, min: 0, max: 5 } })
const attribute = {} as Attributes<{ foo: Attribute }> | Attributes<{ bar: Attribute }>;

if (attribute.hasStat('foo')) { // should work like typeguard without any assertions
  // `attribute` should be narrowed to `Attributes<{ foo: Attribute }>`
  const fooStat = attribute.getStat('foo')
  //    ^? any              ^? error: This expression is not callable...
}

Am I missing something in the implementation of the type guard or elsewhere? How can I ensure attribute is correctly narrowed when using hasStat?


Solution

  • Type predicates of the form arg is Type narrow the apparent type of arg. So with the method signature

    hasStat(name: string): name is keyof T;
    

    you're narrowing the apparent type of the argument passed in for name. That means if (attribute.hasStat('foo')) {}, would, if anything, act on the string literal 'foo', which is not what you're trying to do. Indeed, you're trying to narrow the type of attribute. That means you want to use a this-based type guard like:

    hasStat<K extends string>(name: K): this is Attributes<Record<K, Attribute>>;
    

    I've made that generic, since you need to track the literal type of the name input, and then you are narrowing this from Attributes<T> to Attributes<Record<K, Attribute>>. The exact nature of this narrowing might be out of scope here, since it depends on whether or not TypeScript sees that as a narrowing for a particular T and K. Ideally you want to narrow attribute from a union type to just those members assignable to Attributes<Record<K, Attribute>>, which depends on whether Attributes<T> is considered covariant in T (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript). I won't digress further here.

    Anyway, with this definition your code works as intended:

    const foo: Attributes<{ foo: Attribute }> =
      new Attributes({ foo: { value: 1, min: 0, max: 5 } })
    const bar: Attributes<{ bar: Attribute }> =
      new Attributes({ bar: { value: 1, min: 0, max: 5 } })
    const attribute = Math.random() < 0.5 ? foo : bar
    
    attribute.getStat('foo'); // error
    attribute.getStat('bar'); // error
    
    if (attribute.hasStat('foo')) {
      const fooStat = attribute.getStat('foo') // okay
    } else {
      const barStat = attribute.getStat('bar') // okay
    }
    

    Playground link to code