I have the following Typescript in a playground.
Here is the code:
class Foo {
public get test() : string|number{
return "foo"
}
public hasString() : this is { test:string }{
return typeof this.test === "string";
}
}
const foo = new Foo();
if(!foo.hasString()) {
console.log(foo.test - 10); // <-- this is an error
}
if(typeof foo.test == "number") {
console.log(foo.test - 10);
}
As far as I understand, when hasString()
is false, the only other value it can be is number
. However, typescript still gives an error:
The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type.
Why isn't the typescript compiler smart enough to realize if not string, then it must be a number.
CodePudding user response:
You can't use user-defined type guards to narrow the type of a non-union typed object in the false
case. The main problem with the false
case of a type guard in general is that TypeScript doesn't support arbitrary negated types of the sort implemented (but never merged) in microsoft/TypeScript#29137 where not X
would match all and only values that are not of type X
.
If you have a union type like A | B | C | D
, then it's easy to say that a certain type guard should "split" that type into something like A | B
for a successful test or to C | D
for a failed test. After all, if you have a value of type A | B | C | D
and you know that it isn't A | B
, then it must be C | D
. But if you have a non-union type then there's nothing to split. If you have a value of type X
and you know that it is a Y
, then you can narrow it to the intersection X & Y
. But if it isn't a Y
, there's no type like X & not Y
you can narrow it to; the best the compiler can do is leave it as X
. Type guards of non-union typed objects are therefore essentially asymmetric.
By making hasString()
a type guard on this
, the compiler can narrow a value from Foo
to Foo & {test: string}
in the true
case. But in the false
case there's no Foo & not {test: string}
to narrow to. The compiler leaves it as Foo
.
You can read more about this situation in microsoft/TypeScript#36687
If Foo
were equivalent to {test: string} | {test: number}
, then the type guard would behave as desired. But the type checker does not see {test: string | number}
as equivalent to that type. In general it would be prohibitively expensive for the compiler to propagate unions up out of properties; see microsoft/TypeScript#12052 for more information.
You could try to refactor Foo
to be this way, but class
declarations cannot produce union types, and so you'd need to do something else, maybe like this:
interface FooBase {
test: string | number;
hasString(): this is FooString
}
interface FooString extends FooBase {
test: string;
}
interface FooNumber extends FooBase {
test: number;
}
type Foo = FooString | FooNumber;
const fooMaker = () => ({
get test(): string | number {
return "foo"
},
hasString() {
return typeof this.test === "string";
}
}) as Foo;
const foo = fooMaker();
if (!foo.hasString()) {
console.log(foo.test - 10); // <-- okay
}
That works, but is yucky.
Your example code only seems to care about the test
property. If so, you might want to just do a type guard on the test
property itself instead of on foo
. Foo
might not be a union, but the test
property is one. And we know the compiler can filter those even in the false
case. Indeed, this is what you did in your example:
class Foo {
public get test(): string | number {
return "foo"
}
}
const foo = new Foo();
if (typeof foo.test !== "string") {
console.log(foo.test - 10); // <-- okay
}
If you really want a user-defined type guard for this you could write one:
const isString = (x: any): x is string =>
typeof x === "string";
if (!isString(foo.test)) {
console.log(foo.test - 10); // <-- okay
}
but it's hardly necessary because typeof
already acts as a type guard.
So, my suggestion is just to do the typeof
check directly on the test
property, until and unless negated types are ever introduced in the language, so that it can reason better about Foo
s that are not {test: strings}
s.
CodePudding user response:
In order for Typescript to narrow a type in an else
statement, the type needs to be a Union type. Since Foo
is not of a Union type, Typescript has no type available to narrow foo
to.
There is a related issue in the TS repo https://github.com/microsoft/TypeScript/issues/36887
To leverage typeguards in your usecase you can either check just the property type as it is a string | number
union, or model Foo
using Union types.