1

Why does type guard work for the object itself, but not for the clones of that object? Here is my code (playground link):

if (
  node.data.children.type === NodeDataChildType.CHOICES &&
  node.id === nodeId
) {
  const newNode = { ...node };
  console.log(
    newNode.data.children.choices, // cloned object, giving error
    node.data.children.choices // original object, no errors
  );
}

(Please note: I know both choices above are the same array since object spread just does a shallow clone. I'm logging both of them to demonstrate the type error on the cloned object.)

I'm getting this error:

Property 'choices' does not exist on type 'NodeDataChildren'.
  Property 'choices' does not exist on type '{ type: NodeDataChildType.TEXT | NodeDataChildType.CONTINUE | NodeDataChildType.NONE; }'.ts(2339)

enter image description here enter image description here

Here are the types:

export type Node = {
data: NodeData
id: string;
}
export type NodeData = {
  label?: string;
  children: NodeDataChildren;
};

type NodeDataChildren =
  | {
      type: Exclude<NodeDataChildType, NodeDataChildType.CHOICES>;
    }
  | {
      type: NodeDataChildType.CHOICES;
      choices?: Array<NodeDataChildChoice>;
    };

export enum NodeDataChildType {
  CHOICES = "choices",
  TEXT = "text",
  CONTINUE = "continue",
  NONE = "none",
}
export type NodeDataChildChoice = {
  id: string;
};

Note: This is not built in Node type, but a custom Node Type

What am I doing wrong here?

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
plutolaser
  • 448
  • 4
  • 17
  • I've just added it , please check – plutolaser Aug 19 '22 at 13:16
  • Okay, I'll link that shortly – plutolaser Aug 19 '22 at 13:27
  • Note that your `console.log` is logging the **exact same array** twice. `{ ...node }` only does a shallow copy, not a deep one. Both `node` and `newNode` share the `data` object (and thus its `children` object, and *its* `choices` array). [Here's how to do a deep copy](https://stackoverflow.com/questions/122102/what-is-the-most-efficient-way-to-deep-clone-an-object-in-javascript) if you need one. – T.J. Crowder Aug 19 '22 at 13:31
  • Yes, that is the issue. Both objects reference the same array, yet typescript throws the error when accessing it from a shallow copy object. – plutolaser Aug 19 '22 at 13:34
  • I'm just saying: Why log the same object twice? But if it's just to show the error, that's fair enough. :-) (FWIW: Here's a version using a deep copy without the type error: https://tsplay.dev/mx3BGN, because the type of `children` is narrowed directly.) – T.J. Crowder Aug 19 '22 at 13:35
  • 1
    Yes, that is for demonstrating the error :D, I haven't written the logic yet. Thank you for being patient and understanding my issue! – plutolaser Aug 19 '22 at 13:36

1 Answers1

1

I think you're just hitting one of the many limits of automatic type narrowing. I think the primary problem is that your code is narrowing the type of node.data.children, but then just copying node. Since node's type hasn't been narrowed by a type guard, newNode just gets the plain Node type — and that type has a union for data.children. You know (and we know) that node and newNode share the same data object, and thus the same data.children object, but TypeScript doesn't go that far.

When I run into a limit like that, I like to use a type assertion function, for example:

function assertIsChoicesNodeData(children: NodeDataChildren): asserts children is NodeDataChildrenWithChoices {
    if ((children as any).type !== NodeDataChildType.CHOICES) {
        throw new Error("Expected a children object with `type` = `NodeDataChildType.CHOICES`");
    }
}

Type assertions are a Bad Thing™ most of the time, but a type assertion function is a different beast because it doesn't just trust the programmer, it verifies the assertion at runtime.

Here's how you'd use it:

if (node.data.children.type === NodeDataChildType.CHOICES && node.id === nodeId) {
    const newNode = { ...node };
    assertIsChoicesNodeData(newNode.data.children);
    console.log(node.data.children.choices, newNode.data.children.choices);
}

Playground link

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • 1
    It works! I also got to learn some new stuff. Thank you for your time – plutolaser Aug 19 '22 at 13:57
  • @omar - No worries. I have a nagging feeling there's a better answer out there, though... :-) – T.J. Crowder Aug 19 '22 at 14:00
  • There is a way to avoid this (though looks really powerful), that is by declaring a variable before the if statement and type guard that variable instead of the original object. It will though create a new object on every iteration though. ``` const newNode = { ...node }; if ( newNode.data.children.type === NodeDataChildType.CHOICES && node.id === nodeId ) ``` – plutolaser Aug 19 '22 at 14:03
  • 1
    Well, yes, you can always do that. :-) – T.J. Crowder Aug 19 '22 at 14:04