Search code examples
typescript

How to implement a type guard on a private class property in typescript


I am trying to make a class with a private optional property. Within the class I define a type guard that checks if the value is not undefined. Lastly, I want a function that processes the optional property (if it exists) and returns it. Here is the code:

class DemoClass<X> {
  #val?: X

  constructor(val?: X) {
    this.#val = val
  }

  hasVal(): this is { val: X } {
    return this.#val !== undefined
  }

  extractVal(): X {
    if(this.hasVal()) {
      let val = this.#val!
      // let val = this.val works too
      // Manipulate val
      return val
    }

    throw new Error("Tried accessing val when it doesn't exist")
  }
}

let demoClassInstance = new DemoClass("Test")

if(demoClassInstance.hasVal()) {
  console.log(demoClassInstance.extractVal())
  console.log(demoClassInstance.val) // val is no longer private?
}

The problem is that I am forced to define the private property as #val?: X rather than private val: X since the type guard wouldn't work (as far as I'm aware).

Then, by using #val?: X I am forced to create a type guard like this:

  hasVal(): this is { val: X } {
    return this.#val !== undefined
  }

(I can't do hasVal(): this is { #val: X }) which ends up leaking the val property which should be private.

Hence my question is, what is the correct way to implement a type guard on a private class property?

Playground


Solution

  • There is no way to programmatically manipulate a class instance type and preserve its private or protected or #private keys. There's a feature request at microsoft/TypeScript#35416 which might enable this, but for now it's not part of the language. So you can't say "a type just like DemoClass<X> except the #val property is required and not optional", and thus no way to write a type predicate that narrows an instance of DemoClass<X> to one whose #val property is known to be present. If you want behavior like this, you'll need to work around it and refactor.

    The usual refactoring is to make the class type generic in such a way that you can specify the generic type argument to have the desired effect on the private field. This is made more complicated since your example is already generic, and you cannot affect required/optional with generics alone.

    So we can say that instead of optional, we have a required property that accepts undefined, and when we narrow it, the undefined is removed. And unfortunately that means you need to change the constructor so that this undefined is added to the input type, so then instead of a class statement we need to roll our own construct signature. It's messy and complicated and there are a bunch of possible equivalent approaches. The point here is not to suggest a particular approach. I will present one, but if you want something to act differently, feel free to modify it:

    class _DemoClass<X> {
      #val: X
    
      constructor(val: X) {
        this.#val = val
      }
    
      hasVal(): this is DemoClass<X & ({} | null)> {
        return this.#val !== undefined
      }
    
      extractVal(): X {
        if (this.hasVal()) {
          let val = this.#val
          return val
        }
    
        throw new Error("Tried accessing val when it doesn't exist")
      }
    }
    
    type DemoClass<X> = _DemoClass<X>
    type DemoClassPossiblyMissing<X> = _DemoClass<X | undefined>
    const DemoClass = _DemoClass as {
      new <X>(val?: X): DemoClassPossiblyMissing<X>
    }
    

    What I've done here is to rename the DemoClass class statement out of the way to _DemoClass, so we can then redefine DemoClass as a type and as a constructor. The type is just the same, where DemoClass<X> is _DemoClass<X>. But the constructor is different, as it constructs a DemoClassPossiblyMissing<X> instead of a DemoClass<X>. And DemoClassPossiblyMissing<X> is effectively a DemoClass<X | undefined>.

    So now we can take a type DemoClass<X> and remove undefined from the domain by intersecting with {} | null as DemoClass<X & ({} | null)> (see the intersection reduction support that treats {} | null | undefined as the same as unknown, so intersecting with {} | null is the same as saying "not undefined".)

    Let's test it out:

    let testClassInstance = new DemoClass("Test")
    //  ^? let testClassInstance: DemoClassPossiblyMissing<string>
    if (testClassInstance.hasVal()) {
      testClassInstance
      // ^? let testClassInstance: DemoClass<string>
      console.log(testClassInstance.extractVal())
    }
    

    Looks good, when you create something you get DemoClassPossiblyMissing<string> which is then narrowed to DemoClass<string>.


    Again, you can write this different ways, such as introducing a second generic type parameter just for the undefined part, and narrowing that to never. But the exact workaround is probably out of scope here, and secondary to the general approach of "use generics to get a handle on the types of private properties".

    Playground link to code