Skip to content

type Exclude on class hierarchy resolves to never #64957

Open

Description

@xtnw

🔎 Search Terms

typescript Exclude on class hierarchy resolves to never

🕗 Version & Regression Information

  • I first experienced this behaviour in version 6.0.3.
  • For interfaces, it seems to date back to at least 3.2.2.
  • I tested version 3.3.3333 on typescript playground exhibiting the same behaviour.
  • Could not test typescript 7.0 because playground does not support it.

⏯ Playground Link

https://www.typescriptlang.org/play/?ts=3.3.3&utm_source=chatgpt.com#code/KYDwDg9gTgLgBAYwDYEMDOa4EE4G8BQciEAdmjFAK4IzQAUYlARkgJYJwgBccJlAtk2BQANHEYt2cAJ48+g4QEo8AX3xrQkWIlQY4AITigYwEgBNMOAkQSlyVGvRQ8sYiWw5Me91iQDmytZEcGiUYMJ0KAB0IGLR0ooA3IRwahrg0PDI6JgAwkYgJuaWeCm2ZBTUtFCRLoEpRKHhNdGxcPFJKWn4mpk6OXC5ALIQZgVFFtilNnaVji1cruLMHnBmAvyyvAJCUHAAvHAADPXBIWERrXFRCclE3fj4MNLh2EhIBwZwAD6Dyc+vLDvEZjQ6GX7DUZPF7AOAAZQg-FhhwAoiBkJQzMAADxApBiXIAPkScAA9KS4GjwjRgGYePoxFgaJQUEg5MAAG7CaGvBFIkGfNEYrG44GjAkg4lkimGdAFakmMyPIA

💻 Code

class A {
  constructor(public x: number, public y: number) {}
}
class B extends A {
  constructor(a: A, public b: string) {
    super(a.x, a.y);
  }
}
class C extends A {
  constructor(a: A) {
    super(a.x, a.y);
  }
}
class CMod extends A {
  constructor(a:A, public dummy: number = 0) {
    super(a.x, a.y);
  }
}


type All = B | C;
type AllMod = B | CMod
type Some = Exclude<All, C>; // Expected: B, Actual: never
type SomeMod = Exclude<AllMod, CMod>; // B as expected

🙁 Actual behavior

type Some = Exclude<All, C> yielded never, even though C is a class distinct from A.

If they were interfaces as shown in Stackoverflow issues [1], [2], I'd understand the behaviour as interfaces are matched structurally. But classes should bear distinct type information that makes them discriminable even if their members are the same.

[1] https://stackoverflow.com/questions/54009493/why-does-typescript-give-type-never-when-excluding-extended-interface
[2] https://stackoverflow.com/questions/63185800/typescript-excludeunionoftypes-interface-is-type-never

🙂 Expected behavior

type Some = Exclude<All, C> yields B as does SomeMod.

Additional information about the issue

Given that the behaviour exists since at least 3.3.3333 I'm afraid this is intended.

However, I don't understand why classes do not differ from interfaces from a type system perspective.

I don't like the CMod solution by polluting every instance with an additional dummy member just to make the type system work as expected.

Activity

  1. jcalz commented on Oct 25, 2026

    @jcalz
    Contributor

    Note well: I'm not a TS team member

    TypeScript's type system is structural and the "class"-ness of a type doesn't affect its structure. See Relationships Between Classes in the handbook. If you want types to differ you should make them differ structurally, and adding different members is one way to do this.

    Everything in your example is behaving as intended so there's no bug here. If you're used to nominal typing then your expectations will sometimes be subverted.

    If this were Stack Overflow I'd go on to ask about your use case and try to figure out if this is just a mismatch of expectations, or if it's causing some more concrete problem (since TS isn't perfectly sound and can allow unsafe things to happen) and how we could best deal with it. But it's the GitHub issue tracker and the behavior is intentional so I won't digress further.

  2. xtnw commented on Oct 25, 2026

    @xtnw
    Author

    So my gut feeling was right, and this seems to be intended behaviour. Feel free to close this bug as wontfix.

    Now I only have to find a way to retain sound typing without bloating my classes with unused properties at runtime. But this is, as Joe Calzaretta (@jcalz) correctly stated, not part of this bug report.

  3. irfanstract commented on Oct 25, 2026

    @irfanstract

    Now I only have to find a way to retain sound typing without bloating my classes with unused properties at runtime.

    for now, make the following change (add some private fields):

      class A {
        constructor(public x: number, public y: number) {}
    +   #A: any
      }
      class B extends A {
        constructor(a: A, public b: string) {
          super(a.x, a.y);
        }
    +   #B: any
      }
      class C extends A {
        constructor(a: A) {
          super(a.x, a.y);
        }
    +   #C: any
      }
    
  4. jcalz commented on Oct 25, 2026

    @jcalz
    Contributor

    But that suggestion is adding unused properties at runtime, directly counter to OP's requirement.

    Instead they could use type-only class fields which have no runtime impact:

    export class A {
      declare A: undefined;
      constructor(public x: number, public y: number) {}
    }
    export class B extends A {
      declare B: undefined;
      constructor(a: A, public b: string) {
        super(a.x, a.y);    
      }
    }
    export class C extends A {
      declare C: undefined;
      constructor(a: A) {
        super(a.x, a.y);
      }
    }

    Playground

    which might be sufficient, depending on the use case.


    But again, in the absence of a use case I'd refrain from suggesting anything. I'd really want to see something concrete that goes wrong at runtime if you just allow the assignability of A to C. Maybe something involving instanceof? Like, why do you think you need to distinguish two structurally identical types? You might have a legitimate need, but it's not demonstrated here. I don't mind discussing such things here but it really isn't the place for it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    QuestionAn issue which isn't directly actionable in code

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions