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
}