Search code examples
typescripttypesconditional-typestypescript-types

I want to designate a type according to the conditions of the object property


There is organizational data with a hierarchical structure.

Organizational nodes are divided into Root, Dept, and User.

The object property type is assigned a type suitable for node data.

So, I hope that the node interface suitable for the object type will be attached conditionally.

To solve this problem, I tried Index Types and Conditional Types, but they were not successful.

Any help would be appreciated🙇‍♂️

export enum NodeType {
  root = "root",
  dept = "dept",
  user = "user",
}

interface Node {
  readonly nodeId: string;
  readonly type: NodeType;
  title: string;
  childNodes?: Node[];
}

interface RootNode extends Node {
  type: NodeType.root;
  childNodes?: (DeptNode | UserNode)[];
}

interface DeptNode extends Node {
  type: NodeType.dept;
  childNodes?: (DeptNode | UserNode)[];

  deptId: string;
  companyCode: string;
}

interface UserNode extends Node {
  type: NodeType.user;
  childNodes?: UserNode[];

  employeeNumber: string;
  userPrincipalName: string;
}

/**
 * from hierarchy data to flatting data
 */
function flatting(st: Node) {
  flatCompanyData.push(st);
  if (st.childNodes) {
    st.childNodes.forEach((x) => flatting(x));
  }
}

const companyData: RootNode = {
  nodeId: "0",
  type: NodeType.root,
  title: "Company 1",
  childNodes: [
    {
      nodeId: "0.0",
      type: NodeType.dept,
      title: "Department 1",
      deptId: "A1",
      companyCode: "557",
      childNodes: [
        {
          nodeId: "0.0.0",
          type: NodeType.user,
          title: "User 1",
          employeeNumber: "201911",
          userPrincipalName: "[email protected]",
        },
        {
          nodeId: "0.0.1",
          type: NodeType.user,
          title: "User 2",
          employeeNumber: "201912",
          userPrincipalName: "[email protected]",
        },
      ],
    },
    {
      nodeId: "0.1",
      type: NodeType.dept,
      title: "Department 2",
      deptId: "A2",
      companyCode: "558",
    },
  ],
};
let flatCompanyData: Node[] = [];

flatting(companyData);

// I want to be the type of DeptNode interface because the type of object property is NodeType.dept.
const selectDept = flatCompanyData.filter((x) => x.type === NodeType.dept);
selectDept.forEach((x) => {
  console.log({
    nodeId: x.nodeId,
    type: x.type,
    title: x.title,
    // The type is not affirmed in the node that fits the object property type, so you have to affirm the type yourself.
    deptId: (x as DeptNode).deptId,
    companyCode: (x as DeptNode).companyCode,
  });
});

// I want to be the type of UserNode interface because the type of object property is NodeType.user.
const selectUser = flatCompanyData.filter((x) => x.type === NodeType.user);
selectUser.forEach((x) => {
  console.log({
    nodeId: x.nodeId,
    type: x.type,
    title: x.title,
    // The type is not affirmed in the node that fits the object property type, so you have to affirm the type yourself.
    employeeNumber: (x as UserNode).employeeNumber,
    userPrincipalName: (x as UserNode).userPrincipalName,
  });
});

Solution

  • There's two problems in your code:

    First of all, an element of type Node is not necessarily of type DeptNode just because it has NodeType.dept. Consider the following object:

    {
      nodeId: '0',
      type: NodeType.dept,
      title: 'Company 1'
    }
    

    This is a completely valid Node but not a valid DeptNode as it lacks the fields deptId and companyCode. So the compiler is in fact right when it does not treat it as a DeptNode. But there is a solution to this. Instead of using an array of Nodes you can define a union type of all possible node types and use it as type for flatCompanyData like this:

    type NodeUnion = RootNode | DeptNode | UserNode;
    let flatCompanyData: NodeUnion[];
    
    // ...
    
    const node = flatCompanyData[0];
    if (node.type === NodeType.dept) {
      // x is correctly inferred as DeptNode in here
    }
    

    With this type the compiler knows, the only way an element in the array could possibly have the type NodeType.dept is if it is a DeptNode and so the type is inferred correctly.

    This still leaves us with the second problem. Even if you use this union type for flatCompanyData, your code will still not work. The reason is the Array.filter function. For an array of type T[] it will always return an array of type T[], no matter what you check inside.

    But fortunately typescript comes with a handy solution for this: custom type guards. When defining a function (including an anonymous function) you can use a type predicate as its return type to tell the compiler that this function acts as a type guard.

    function isDeptNode(node: NodeUnion): node is DeptNode {
      return node.type === NodeType.dept;
    }
    

    And now you can use this function inside the filter to make typescript realize what you are doing.

    const selectDept: DeptNode[] = flatCompanyData.filter(isDeptNode);
    

    It is also possible to specify a type predicate as the return type of an anonymous function without defining the isDeptNode function first.

    const selectDept: DeptNode[] = flatCompanyData.filter(
      (x): x is DeptNode => x.type === NodeType.dept
    );