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.