Home > OS >  typescript typeguard make type of instance never (unexpected behavior)
typescript typeguard make type of instance never (unexpected behavior)

Time:12-05

When type checking is performed through type guard, it become never type outside the conditional statement.

You can test here

class Fruite {
  num: number;
  constructor(num: number) {
    this.num = num;
  }
}
class Apple extends Fruite {
  constructor(num: number) {
    super(num);
  }
}
class Banana extends Fruite {
  constructor(num: number) {
    super(num);
  }
}

const items = [new Apple(1), new Banana(10)];

const clone = (items: Fruite[]) => {
  return items.map(item => {
    if (item instanceof Apple) {
      return new Apple(item.num);
    } else if (item instanceof Banana) {
      return new Banana(item.num); // works well
    }
    return new Banana(item.num); // works well
  });
}

// ---- ERROR PART ----

const isApple = (item: Fruite): item is Apple => {
  return item instanceof Apple;
}

const isBanana = (item: Fruite): item is Banana => {
  return item instanceof Banana;
}

const clone2 = (items: Fruite[]) => {
  return items.map(item => {
    if (isApple(item)) {
      return new Apple(item.num);
    } else if (isBanana(item)) {
      return new Banana(item.num); // item is never !
    }
    return new Banana(item.num); // item is never !
  });
}

Why does the typescript determine the item as never?

CodePudding user response:

The problem is that Apple and Banana are structurally identical, and since the TypeScript type system is largely structural and not nominal, it considers the two types equivalent. That is, the fact that Apple and Banana have two different declarations does not mean that TypeScript considers them to be different types. So if you write code where types are checked structurally, the compiler will decide that if something is an Apple then it must be a Banana and vice versa.

In your clone function, you are directly performing an instanceof type guard check. And instanceof narrowing compares types nominally and not structurally, because that's usually what people want.

On the other hand, when you refactored this check into user-defined type guard functions isApple() and isBanana(), such nominal narrowing only happens inside the bodies of these functions. The return type of the function, item is Apple or item is Banana doesn't convey any such special nominal flavor to the callers, so the type checker does the standard structural type check. And so, inside clone2, the compiler thinks it is impossible for item to be a Banana if it is not an Apple. And you get an error.

(Yes, these two forms of type checking aren't fully consistent with each other. This is intentional; see microsoft/TypeScript#33481 for more information.)


Anyway, generally speaking, you'll have better results if you make sure any two types that are meant to be different should differ structurally, by adding differing members to them. For example:

class Apple extends Fruite {
  readonly type = "Apple";
  constructor(num: number) {
    super(num);
  }
}
class Banana extends Fruite {
  readonly type = "Banana";
  constructor(num: number) {
    super(num);
  }
}

Now both classes have a type property of differing string literal type. This clears up the problem with clone2():

const clone2 = (items: Fruite[]) => {
  return items.map(item => {
    if (isApple(item)) {
      return new Apple(item.num);
    } else if (isBanana(item)) {
      return new Banana(item.num); // okay
    }
    return new Banana(item.num); // okay
  });
}

Again, just about any distinguishing property will work. For classes you can add a private or protected member and as long as they have separate declarations the classes will be considered distinct (that is, private/protected properties are compared nominally and not structurally):

class Apple extends Fruite {
  private prop: undefined;
  constructor(num: number) {
    super(num);
  }
}
class Banana extends Fruite {
  private prop: undefined;
  constructor(num: number) {
    super(num);
  }
}

It's up to you how you want to distinguish your classes. Ideally they would actually have distinct structures naturally, because you're using them for different purposes. Maybe a Banana has a ripeness property and a removeFromBunch() method, while an Apple has a variety property and a keepTheDoctorAway() method. But that depends on your use cases.

Playground link to code

  • Related