Home > Software design >  TypeScript - The property does not exist in the type, but it actually exists
TypeScript - The property does not exist in the type, but it actually exists

Time:02-12

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);
  }
}

Playground

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.

Playground

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 using in 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.


Playground link to code

  • Related