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 Foos that are not {test: strings}s.
Playground link to code