Home > Software design >  Evaluating false for user defined type guards in classes
Evaluating false for user defined type guards in classes

Time:08-19

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

Playground link to code

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.

  • Related