Home > Mobile >  Understanding TS' type inferring/narrowing with combination of extends & implements
Understanding TS' type inferring/narrowing with combination of extends & implements

Time:10-20

I have the following example code

class B implements Error {
  name: string = '';
  message: string = '';
  // stack?: undefined | string;
}

function Foo(x: any) {
  if(x instanceof Error) {
    if(x instanceof B) {
      x.stack;  // works
    }
  }
}

abstract class C {
  someProp: number = 0;
}

class D extends C implements Error{
  name: string = '';
  message: string = '';
  // stack?: undefined | string
}

function Bar(x: any) {
  if(x instanceof Error) {
    if(x instanceof D) {
      x.stack // does not work: Property 'stack' does not exist on type 'D'.
    }
  }
}

I don't really understand why trying to access stack in Bar(..) fails but works in Foo(..).

Why does extends make this fail for Bar after having narrowed to Error in the outer if?

CodePudding user response:

There are a few things going on here, but I'd say the primary issue is that TypeScript's type system is not completely sound, in particular around optional properties.

TypeScript allows you to assign an object without a known property to a type with that property as an optional property. So {x: string} is assignable to {x: string, y?: number}. This is very convenient, but it's not sound, since all the compiler really knows about a value of type {x: string} is that it has an x property of type string. It doesn't know anything about the y property, so it can't know that the y property is a number or missing. It's a known unsoundness mentioned in the TypeScript GitHub repository issues list in several places, such as microsoft/TypeScript#47499. Again, it's convenient; imagine if you wrote const z = {x: "hello"} and then couldn't assign z to a variable of type {x: string, y?: number}. That would be safe, but so annoying to use that people would be unhappy.

TypeScript also allows the (relatively) sound operation of assigning an object with a known property (optional or otherwise) to a type without that known property. So {x: string, y?: number} is assignable to {x: string}. Object types are open, not sealed. (See this Q/A for more information.)

Combining those means that types with optional properties are mutually assignable with types without known properties, so when the compiler narrows or widens one type to another, it can end up forgetting about optional properties depending on the exact set of operations and the order.


Your example code is equivalent to

class B {
    name: string = '';
    message: string = '';
}

function Foo(x: any) {
    if (x instanceof Error) {
        if (x instanceof B) {
            x.stack;  // okay
        }
    }
}

class D {
    someProp: number = 0;
    name: string = '';
    message: string = '';
}

function Bar(x: any) {
    if (x instanceof Error) {
        if (x instanceof D) {
            x.stack // error!
        }
    }
}

Note that the implements clauses on your classes have no effect on their instance types (see this Q/A for more information), so you might as well remove implements Error as it doesn't do anything. Similarly the extends C part of your class only serves to have D inherit the someProp property, so you might as well define it directly in D.

The issue here is that Error is considered to be assignable to B, even though it's missing Error's optional stack property. When you narrow x from Error to B, the compiler doesn't see it as needing to narrow at all. The type stays Error, and thus x has an optional stack property.

On the other hand, Error is not assignable to D, since D has a required someProp property that Error doesn't necessarily have. D is considered to be narrower than Error. When you narrow x from Error to D, the compiler dutifully narrows to D. And now x has no known stack property, because D doesn't.

So there you go. TypeScript has some unsoundness that's allowed for convenience, and effects of this unsoundness can crop up in some strange places.

Playground link to code

  • Related