Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Empty subclasses refute their parent in failed type guards #53199

Closed
adimit opened this issue Mar 10, 2023 · 4 comments
Closed

Empty subclasses refute their parent in failed type guards #53199

adimit opened this issue Mar 10, 2023 · 4 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@adimit
Copy link

adimit commented Mar 10, 2023

Bug Report

When a class B inherits from A but doesn't actually have any fields of its own it is treated as identical to A for the purposes of refuting a type guard. I ran into this in the typings for the axios project. I'll give an example below.

🔎 Search Terms

type guard, inheritance, subclass

🕗 Version & Regression Information

This problem exists for all 4.x versions.

⏯ Playground Link

Playground link with relevant code

💻 Code

class A {
  message: string;
  constructor(message: string) {
    this.message = message;
  }
}

class B extends A {
  // declare message: "I am B"; // ← workaround
  constructor() {
    super("I am B");
  }
}

function isB(obj: any): obj is B {
  return "message" in obj && obj.message === "I am B";
}

function succeeds(test: A) {
  if (isB(test)) {
    console.log(test.message);
  } else {
    // test is `never`
    console.log(test.message);
  }
}

🙁 Actual behavior

In the else branch above test has type never, and therefore test.message is inaccessible.

🙂 Expected behavior

Refutation of B should not refute A. (Though I'm aware that these two types might just be treated as the same type by TS and that's not really a bug, but a feature.)

🦌 The Bug in the Wild

There's a workaround (indicated by the comment above, you have to include something that differentiates the type). I ran into this in the typings for axios where there's a class CanceledError extends AxiosError that only differs from AxiosError by a special value for one of its fields. In essence, that's a reification of the type similar to a tagged union. Since axios is written in JS, the types for it didn't really reflect that.

Here's the original issue we ran into and Here's the offending subclass, with its type here.

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Mar 10, 2023
@RyanCavanaugh
Copy link
Member

This is effectively an error in the implementation of isB, it can only write obj is B if it returns results which are true for all structural B, which includes A.

@adimit
Copy link
Author

adimit commented Mar 10, 2023

OK, so the fix here is to keep the types structurally distinct, as TS intends to compare them only structurally. Got it, thanks.

I'll close this, since it's Working as Intended.
(And thanks for the swift anwser ❤️)

@adimit adimit closed this as completed Mar 10, 2023
@fatcerberus
Copy link

@adimit Also watch out for cases where two classes are structurally identical but otherwise unrelated. TS will treat them as the same type. (the workaround being to add private properties)

@adimit
Copy link
Author

adimit commented Mar 10, 2023

Thanks for the hint, @fatcerberus. It all made sense once I started thinking about classes as sugar for interfaces. I guess class just evokes Java in me and I got blindsided, but there's nothing special to a class in TS.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

3 participants