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?
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?
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


this isinstead ofname is. That makes perfect sense now. With a slight modification (this is Attributes<Record<K, T[K]>>), the narrowing works almost perfectly. That said, I ran into an issue when both types in the union share a key. In those cases, the type guard doesn’t seem to work as expected. Do you know why this might be happening? Here's a playground link with an example. – stambolievv Commented Jan 2 at 15:06Attributes<X> | Attributes<Y>that isn't better served byAttributes<X | Y>but I guess we can't delve into that here. – jcalz Commented Jan 2 at 16:20this is, and not worry too much about the union stuff. – jcalz Commented Jan 2 at 16:21