typescript - How to handle common properties in a union of generic class instances - Stack Overflow

admin2025-05-01  0

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.

Share Improve this question edited Jan 2 at 21:19 stambolievv asked Jan 2 at 20:19 stambolievvstambolievv 457 bronze badges 7
  • 1 As I mentioned in the other question it has nothing to do with the type guard. So you might want to edit the question to remove the red herring about type guarding, since the issue happens on the union no matter what. As for how to solve it, I'd say you should put the union in the type argument like this as I mentioned before, but if that's not possible for your use case, then I'm stumped, because there's just no support for unions-of-generic-call-signatures in TypeScript. How do you want to proceed here? – jcalz Commented Jan 2 at 20:28
  • Thank you for pointing that out! I’ve updated the question to remove the emphasis on type guards and focus on the issue with unions. – stambolievv Commented Jan 2 at 21:11
  • Regarding your suggested solution of putting the union in the type argument (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 form Attributes<{ 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:11
  • I don't understand what you mean by "inherited". Still, you could try to emulate the behavior of putting the union in type argument by making getStat() use a generic this 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:50
  • Firstly, thank you for taking the time to answer this and the previous question. I really appreciate it. Can't explain it better, so let me show what I mean by inherit 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
 |  Show 2 more comments

2 Answers 2

Reset to default 2

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
}
转载请注明原文地址:http://anycun.com/QandA/1746098457a91644.html