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.
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.
Playground link to code
There's actually a much simpler solution to this problem. Since we know that getStat()
always returns an Attribute
type regardless of the key—because accessing an invalid key would throw an error (as seen in the original code)—we can simplify its method signature to:
getStat(name: keyof T & string): Attribute;
With this simplified signature, your example code works as expected:
attribute.getStat('foo'); // This errors as 'foo' might not exist
attribute.getStat('bar'); // This errors as 'bar' might not exist
attribute.getStat('baz'); // !!! This works as 'baz' exists in both types !!!
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 works as 'baz' exists in both variants !!!
} else {
const neverStat = attribute.getStat('foo') // This case should never occur as 'baz' always exists and should error
}
Attributes<{ foo: Attribute, baz: Attribute } | { bar: Attribute, baz: Attribute }>
), unfortunately, that approach isn't feasible for my use case. The types are inherited in the formAttributes<{ foo: Attribute, baz: Attribute }> | Attributes<{ bar: Attribute, baz: Attribute }>
, and I don’t have control to set the union at the type argument level manually. Additionally, I’d prefer not to use type assertions to resolve this, as I know it's one if the solutions. – stambolievv Commented Jan 2 at 21:11getStat()
use a genericthis
parameter and forcing the union into a common place. It's ugly, though. Looks like this playground link. Does this fully address the question (i.e., you can't do it directly without resolving the union-of-call-signatures into a single call signature)? If so I'll write an answer explaining; if not, what's missing? – jcalz Commented Jan 2 at 22:50inherit
in this playground as in real life usage. As for your solution, it works, but as you say it is kinda hacky/ugly, and I don't really want to use it. I will try to find a different solution, or someone else might have a better idea. Thank you again for your time and effort. – stambolievv Commented Jan 2 at 23:45