I'm working with a union type of two instances of a generic class. These instances have different property shapes, but they also share common properties. I'm encountering an issue when trying to safely access these shared properties.
In my previous question, I received help implementing a this
-based type guard. The solution was to use:
hasStat<K extends string>(name: K): this is Attributes<Record<K, Attribute>>
This solved the initial type narrowing issue, but I've encountered a new problem with common properties. Here's what I'm working with, rest of the code in this playground:
const irrelevant = { value: 1, min: 0, max: 5 };
const foo: Attributes<{ foo: Attribute, baz: Attribute }> = new Attributes({ foo: irrelevant, baz: irrelevant })
const bar: Attributes<{ bar: Attribute, baz: Attribute }> = new Attributes({ bar: irrelevant, baz: irrelevant })
const attribute = Math.random() < 0.5 ? foo : bar
attribute.getStat('foo'); // This errors as 'foo' might not exist
attribute.getStat('bar'); // This errors as 'bar' might not exist
attribute.getStat('baz'); // !!! This should work as 'baz' exists in both types, but errors
if (attribute.hasStat('foo')) {
const fooStat = attribute.getStat('foo') // This works as we've narrowed to the foo variant
} else {
const barStat = attribute.getStat('bar') // This works as we've narrowed to the bar variant
}
// common stat
if (attribute.hasStat('baz')) {
const fooStat = attribute.getStat('foo') // This errors as we haven't narrowed to either variant
const barStat = attribute.getStat('bar') // This errors as we haven't narrowed to either variant
const bazStat = attribute.getStat('baz') // !!! This should work as 'baz' exists in both variants, but errors
} else {
const neverStat = attribute.getStat('foo') // This case should never occur as 'baz' always exists and should error
}
TypeScript still complains about accessing common properties (baz
), even though it exists in both variants. How can I modify the type predicate to handle these cases correctly?
Minimal playground focusing on the issue.
The problem is that TypeScript has only limited support for calling unions of call signatures. Indeed, before TypeScript 3.3, you could only call unions of call signatures if each member of the union were identical. TypeScript 3.3 introduced improved support for calling unions as implemented in microsoft/TypeScript#29011, but it only works if at most one of the members of the union is either a generic function or an overload.
In your case, you're trying to call a.getStat()
when a
is of type Attributes<X> | Attributes<Y>
. But that looks like the union (<K extends keyof X & string>(name: K): X[K]) | (<K extends keyof Y & string>(name: K): Y[K])
. Both of the union members are generic function types, so you can't call it. That is, you get the error:
a.getStat("baz") // error!
// Each member of the union type
// '(<K extends keyof X>(name: K) => X[K]) | (<K extends keyof Y>(name: K) => Y[K])'
// has signatures, but none of those signatures are compatible with each other.
Maybe in a perfect world, TypeScript could unify those to become the equivalent <K extends keyof (X | Y) & string>(name: K): (X | Y)[K]
, but currently the only way to do that is to explicitly widen the type of a
from Attributes<X> | Attributes<Y>
to Attributes<X | Y>
directly:
const b: Attributes<X | Y> = a;
b.getStat("baz"); // okay
If you don't want to do that, then you're going to have to really jump through some type system hoops. The only way you'll be able to call a union of generic call signatures is if those are identical call signatures. That implies we need to change Attributes<T>.getStat
so that its type depends on the actual type of the object you're calling it on, and not directly on the type of the T
type parameter . In other words: we need to use a this
parameter and make the function even more generic. Something like this:
type DeAttributes<A extends Attributes<any>> =
A extends Attributes<infer T> ? T : never;
declare class Attributes<T extends Record<string, Attribute>> {
// ⋯
getStat<
A extends Attributes<any>,
K extends keyof DeAttributes<A> & string
>(this: A, name: K): DeAttributes<A>[K];
}
So now getStat()
is generic in A
, the type of this
(which we expect to be a union like Attributes<X> | Attributes<Y>
. To recover T
from A
, we have to pass it through the DeAttributes<A>
utility type, which uses a distributive conditional type to convert unions in A
to unions in T
. So DeAttributes<Attributes<X> | Attributes<Y>>
will be X | Y
. And K
is constrained to keyof Deattributes<A>
(instead of keyof T
) and we return DeAttributes<A>[K]
(instead of T[K]
).
And now when we call it, it works, because the call signatures don't depend on the type parameter T
. Each member of the union is <A extends Attributes<any>, K extends keyof DeAttributes<A> & string>(this: A, name: K) => DeAttributes<A>[K]
. The A
type parameter is specified with Attributes<X> | Attributes<Y>
, and K
is specified with "baz"
:
a.getStat("baz") // okay
// (method) Attributes<T>.getStat<Attributes<X> | Attributes<Y>, "baz">(
// this: Attributes<X> | Attributes<Y>, name: "baz"
// ): Attribute
which returns Attribute
as desired.
This all works, but it's complicated because you're emulating the widening of Attributes<X> | Attributes<Y>
to Attributes<X | Y>
. I'd expect it might be fragile, with strange edge-case behavior where this approach breaks down. Maybe it's good enough for your needs, but it fights against the type system, and that shows.