I'm trying to make my own xml builder on typescript. I have this issue:
Property 'children' does not exist on type 'XMLNode'.
Property 'children' does not exist on type 'XMLNodeWithoutClidren'.
its my code:
export interface Stringable {
toString(): string
}
export type XMLProperty<T extends Stringable> = T
export interface XMLNodeWithoutClidren {
readonly attributes: Map<string, XMLProperty<Stringable>>;
}
export interface XMLNodeWithClidren extends XMLNodeWithoutClidren {
readonly children: Set<XMLNode>;
}
export type XMLNode = XMLNodeWithoutClidren | XMLNodeWithClidren;
class NodeWithoutChildrenBuilder {
static build(node: XMLNodeWithoutClidren): string {
return `some logic`
}
}
class NodeWithChildrenBuilder {
static build(node: XMLNodeWithClidren): string {
return `some logic`
}
}
class NodeParser {
static parse(node: XMLNode) {
if (node.children) {
return NodeWithChildrenBuilder.build(node);
}
return NodeWithoutChildrenBuilder.build(node);
}
}
What's wrong with my code?
CodePudding user response:
Sounds like a common case for a custom type guard:
function nodeHasChildren(node: XMLNode) : node is XMLNodeWithClidren {
return (node as XMLNodeWithClidren).children !== undefined;
}
and then you can rewrite your
if (node.children) {
to
if (nodeHasChildren(node)) {
which should correctly narrow the type in the scope.
CodePudding user response:
If you try to access a property which isn't known to exist on an object, the compiler will warn you about it:
interface Foo {
x: string;
}
function f(foo: Foo) {
foo.y // <-- error, property 'y' does not exist on type 'Foo'
}
This doesn't mean that the property cannot exist, it's just that the compiler has no idea if the property exists or not, and so it considers the access a probable error. And because it has no idea if the property exists or not, it treats it as having the any
type, because it could be anything (not necessarily undefined
):
const b = { x: "", y: 1 };
f(b); // okay
In your case, you are trying to access the children
property on an object which is a union of two types: XMLNodeWithChildren
which is known to have a children
property of a certain type, and XMLNodeWithoutChildren
which is not.
Despite the name, the compiler cannot know that XMLNodeWithoutChildren
lacks a children
property. Indeed, your own code defines XMLNodeWithChildren
as an extension of XMLNodeWithoutChildren
. By the rules of subtyping, every XMLNodeWithChildren
is also a perfectly valid XMLNodeWithoutChildren
. And there are all kinds of potential perfectly valid XMLNodeWithoutChildren
extensions:
interface XMLNodeWithNumericChildren extends XMLNodeWithoutChildren {
children: number;
}
So, technically, faced with an object of type XMLNodeWithChildren | XMLNodeWithoutChildren
, the compiler has no idea what, if anything, can be concluded about the children
property. It would be unsafe for the compiler to think that the type of the property is Set<XMLNode> | undefined
. It has to consider it any
, and that doesn't help with control flow narrowing.
Case in point, let's imagine that NodeWithChildrenBuilder.build()
actually does something Set
-like with its argument's children
property:
class NodeWithChildrenBuilder {
static build(node: XMLNodeWithChildren): string {
node.children.has(node); // compiles fine
return `some logic`
}
}
You can then cause a runtime error if you call NodeParser.parse()
with an argument with some random children
property, and the compiler is not guaranteed to catch this:
const x = { attributes: new Map(), children: 123 }
NodeParser.parse(x) // no compiler error, but RUNTIME ERROR!
That's what's "wrong" with your code.
Of course, in practice, it seems unlikely that someone will hand you a pathological node
of a type like XMLNodeWithNumericChildren
. And so while it's technically unsafe to use the presence of children
to narrow an XMLNode
to an XMLNodeWithChildren
, you'd like to do it anyway.
If so, the easiest way for you to deal with this is to use the in
operator type guard like this:
class NodeParser {
static parse(node: XMLNode) {
if ("children" in node) { // <-- in operator here
return NodeWithChildrenBuilder.build(node); // okay
}
return NodeWithoutChildrenBuilder.build(node); // okay
}
}
This works with no compiler errors now. Note that this is still technically unsafe. Someone can still call NodeParser.parse(x)
like above and get a runtime error. But this lack of safety is considered acceptable in order to give people a way to do the kind of check you're doing: see this comment on microsoft/TypeScript#15256, the PR that implemented in
guarding:
We considered the soundness aspect and it's not very different from existing soundness issues around aliased objects with undeclared properties. [...] The reality is that most unions are already correctly disjointed and don't alias enough to manifest the problem. Someone writing an
in
test is not going to write a "better" check; all that really happens in practice is that people add type assertions or move the code to an equally-unsound user-defined type predicate. On net I don't think this is any worse than the status quo (and is better because it induces fewer user-defined type predicates, which are error-prone if written usingin
due to a lack of typo-checking).
So, one solution is for you to change if (node.children)
to if ("children" in node)
, forget about pathological XMLNode
values, and move on with your life!
If upon reading the above though, you still worry about pathological cases, you can refactor your types so that XMLNodeWithoutChildren
actually cannot have a defined children
property value. It could look like this:
interface BaseXMLNode {
readonly attributes: Map<string, XMLProperty<Stringable>>;
}
export interface XMLNodeWithoutChildren extends BaseXMLNode {
children?: never;
}
export interface XMLNodeWithChildren extends BaseXMLNode {
readonly children: Set<XMLNode>;
}
export type XMLNode = XMLNodeWithoutChildren | XMLNodeWithChildren;
Now we have a BaseXMLNode
that both XMLNodeWithoutChildren
and XMLNodeWithChildren
derive from. The XMLNodeWithoutChildren
type has an optional children
property of the impossible never
type. Since there are no values of type never
, an optional property of type never
is essentially the same as a prohibited property. The only value you'll ever see at the children
property is undefined
.
And that means XMLNode
is now a truly discriminated union where the children
property can be used to tell which type of XMLNode
you have:
Once we do that, your original NodeParser
code works again:
class NodeParser {
static parse(node: XMLNode) {
if (node.children) {
return NodeWithChildrenBuilder.build(node); // okay
}
return NodeWithoutChildrenBuilder.build(node); // okay
}
}
and now those pathological cases are impossible, since children
needs to either be a Set<...>
or undefined
:
const x = { attributes: new Map(), children: 123 }
NodeParser.parse(x) // error!
// ------------> ~
// Types of property 'children' are incompatible.
This solution is as type-safe as you can get; it might or might not be worth the refactoring to get such type safety, depending on your use cases.