Search code examples
typescriptnarrowing

ts narrowing with getter


I , am actually look for a cheap solution to allow ts narrowing with a getter or string compare instead of a instanceAB instanceof instanceA to get narrowing.

Is ts have some solution for this?

export class Token {
   type:'TokenContainer'|'TokenPrimitive'|null = null;
   get isContainer(){
      return this.type === 'TokenContainer'
   }
      get isPrimitive(){
       return this.type === 'TokenPrimitive' 
   }
    foo0(){}
}

export class TokenContainer extends Token {
   type:'TokenContainer' = 'TokenContainer';
   foo1(){}
   
}

export class TokenPrimitive extends Token {
    type:'TokenPrimitive' = 'TokenPrimitive'
     foo2(){}
}


let test!:Token;
//🟢 is possible to make this work ?.
if(test.isContainer){
   test.foo1()
}
//🟢 is possible to make this work ?
if(test.type === 'TokenContainer' ){
   test.foo1()
}

//🔴 instead of this
if(test instanceof TokenContainer ){
   test.foo1()
}

link: tsPlayground


Solution

  • I'm going to assume that you are not looking to refactor your code so as to change the runtime behavior or even the emitted JavaScript. That is, the changes I will make to your code only affect the TypeScript type checker.


    Property getters in TypeScript behave essentially the same as regular properties when it comes to narrowing. There is a suggestion at microsoft/TypeScript#43368 to allow property getters to act as user defined type guard functions, but currently there is no such facility in the language. So if you want to get the type checker behavior you want, we'll have to do it in a way that would also work if isContainer and isPrimitive were just normal properties.


    That means both your isContainer/isPrimitive checks as well as your type check need to act as a type guard on the type of the test object. In TypeScript, the only way a property check can act as a type guard on the type of the containing object is if the object's type is a discriminated union and the property is a discriminant of that union.

    The Token type is not a discriminated union. It's not even a union at all; it's just a class instance interface. So if test is annotated as Token, then what you want is not possible. The first step toward enabling this, then, is to annotate test as a union type:

    type SomeToken = TokenContainer | TokenPrimitive;            
    declare let test: SomeToken;
    

    And we now have to ensure that SomeToken is a discriminated union with isContainer, isPrimitive, and type being discriminant properties.


    Well, the type property is already a discriminant. The TokenContainer and TokenPrimitive both have a type property whose type is a distinct string literal type. So that check will immediately work:

    if (test.type === 'TokenContainer') {
       test.foo1() // okay
    } else {
       test.foo2() // okay
    }
    

    The story is different for isContainer and isPrimitive. They are only declared on the Token superclass and are inherited by the subclasses. If you declare them explicitly on the subclasses, of appropriate hardcoded boolean literal types, this will start to work. But we don't want to make any runtime changes by explicitly overriding them, so you instead need to do something like the declare property modifier in the subclasses, which unfortunately are not legal when the parent property is a getter; see microsoft/TypeScript#40220, so the current workaround is to use (yuck) a //@ts-ignore comment:

    export class TokenContainer extends Token {
       type: 'TokenContainer' = 'TokenContainer';
       //@ts-ignore
       declare readonly isContainer: true;
       //@ts-ignore
       declare readonly isPrimitive: false;
       foo1() { }
    }
    
    export class TokenPrimitive extends Token {
       type: 'TokenPrimitive' = 'TokenPrimitive'
       //@ts-ignore
       declare readonly isContainer: false;
       //@ts-ignore
       declare readonly isPrimitive: true;
       foo2() { }
    }
    

    You can verify that this works:

    if (test.isContainer) {
       test.foo1() // okay
    } else {
       test.foo2() // okay
    }
    

    But I don't recommend this until and unless microsoft/TypeScript#40220 is implemented.


    Instead you could make the parent class property type conditional depending on the type of the implementing subclass as represented by the polymorphic this type. That is, you leave TokenContainer and TokenPrimitive alone, but assert that the getters return something dependent on the actual subclass type:

    export class Token {
       type: 'TokenContainer' | 'TokenPrimitive' | null = null;
       get isContainer() {
          return (this.type === 'TokenContainer') as 
            this extends TokenContainer ? true : false
       }
       get isPrimitive() {
          return (this.type === 'TokenPrimitive') as 
            this extends TokenPrimitive ? true : false
       }
       foo0() { }
    }
    

    The types like this extends TokenContainer ? true : false are more or less unresolved generic types inside the class implementations, but when you use instances of the subclasses, they will resolve to either true or false. And then this will also work:

    if (test.isContainer) {
       test.foo1() // okay
    } else {
       test.foo2() // okay
    }
    

    Playground link to code