TypeScript TypeGuard Issue with Generic Class and Union Types - Stack Overflow

admin2025-05-02  1

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?

Share Improve this question edited Jan 2 at 20:32 jonrsharpe 122k30 gold badges268 silver badges476 bronze badges asked Jan 2 at 3:13 stambolievvstambolievv 457 bronze badges 3
  • First of all, sorry for the delayed response! Thank you for pointing out that I need to use this is instead of name 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:06
  • That has nothing to do with the type guard (surely it doesn't narrow at all, since both types share the key you're checking for), so it's out of scope here. It's just that you cannot call unions of generic methods; calling unions of things in general is only supported to some extent. I still don't quite see the use case for Attributes<X> | Attributes<Y> that isn't better served by Attributes<X | Y> but I guess we can't delve into that here. – jcalz Commented Jan 2 at 16:20
  • (see prev comment) I will post an answer mentioning this is, and not worry too much about the union stuff. – jcalz Commented Jan 2 at 16:21
Add a comment  | 

1 Answer 1

Reset to default 1

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

转载请注明原文地址:http://www.anycun.com/QandA/1746135582a92064.html