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?
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".