[FIXED] Best way to build a network of Nodes from JSON to classes

Issue

I have a collection of elements that forms a net of measurements and are related in a recursive manner. I need to store them in a class of objects for storing in a Cassandra database. I have built the individual classes for creating each object. However, I am trying to figure out how to convert them from the JSON object to the classes. I cannot use a third party library for this unfortunately due to some restrictions with using unvetted libraries. Here is the structure: The goal is to have a list of PNodes the super class.

type classification = 'ADVERSE' | 'MODEST' | 'EXTREME';

interface PNode {
  classification?: string;
  qualityMetric?: number;
  nextPhase?: PNode;
}

interface AlphaNode {
  beta?: PNode;
  gamma?: PNode;
  nodeOptions: Array<PNode>;
}

interface BetaNode extends PNode {
  ranking: number;
}

interface GammaNode extends PNode {
  effectMetric: number;
}

Here is a sample of input data:

{
  "pnodes": [
    {
      "betaNode": {
        "classification": "ADVERSE",
        "qualityMetric": 5,
        "ranking": 3,
        "nextPhase": {
          "gammaNode": {
            "classification": "MODEST",
            "effectMetric": 2.2
          }
        }
      }
    },
    {
      "gammaNode": {
        "nodeOptions": [
          {
            "betaNode": {
              "classification": "EXTREME",
              "qualityMetric": 5,
              "ranking": 3,
              "nextPhase": {
                "alphaNode": {
                  "betaNode": {
                    "classification": "ADVERSE",
                    "effectMetric": 1.3
                  }
                }
              }
            }
          }
        ]
      }
    }
  ]
}

Solution

Preface

Ok after wracking my brain about what exactly you are looking for in your question – I think I may understand and have a solution.

To my understanding you want to take this JSON input as a string, parse it into a javascript object and convert this object to use a nested class structure defined by the types you defined above. If that was not your intent I have no clue what was.


Defining desired output types

Interfaces

First of all, I want to define the desired outputs as interfaces to simplify the logic, then we will implement these in class form next. These interfaces are prefixed with an I to differentiate them from the classes (i.e. IBetaNode). However, the interface types you provided do not work for the JSON input data for a few reasons.

The nextPhase of the IPNode cannot be IPNode because this would assume either the nextPhase cannot be an alpha node or that all the possible node classes for alpha, beta and gamma, extend from IPNode. The first is not the case as the example input has an alphaNode as a nextPhase, the second is true for gamma and beta but not alpha. So nextPhase must be a union of alpha or beta or gamma. This also applies to nodeOptions of the alpha node interface.

The also the beta and gamma types on the alpha node can be stricter because they are bound to one type of node.

type Classification = 'ADVERSE' | 'MODEST' | 'EXTREME';

interface IPNode {
  classification?: Classification;
  qualityMetric?: number;
  nextPhase?: IAlphaNode | IBetaNode | IGammaNode;
}

interface IAlphaNode {
  beta?: IBetaNode; // always BetaNode
  gamma?: IGammaNode; // always GammaNode
  nodeOptions?: Array<IAlphaNode | IBetaNode | IGammaNode>;
}

interface IBetaNode extends IPNode {
  ranking: number;
}

interface IGammaNode extends IPNode {
  effectMetric: number;
}

Classes

The classes are effectively the same as the interfaces notice each class implements the respective interface defined above. All properties applied when the class is instantiated, you could mutate values after but that is not necessary for the provided solution. One minor difference here is that the nodeOptions on the AlphaNode class is a required property but the constructor and IAlphaNode permit it as optional which would simply defaults to [].

class PNode implements IPNode {
  classification?: Classification;
  qualityMetric?: number;
  nextPhase?: AlphaNode | BetaNode | GammaNode;

  constructor(classification?: Classification, qualityMetric?: number, nextPhase?: AlphaNode | BetaNode | GammaNode) {
    this.classification = classification;
    this.qualityMetric = qualityMetric;
    this.nextPhase = nextPhase;
  }
}

class AlphaNode implements IAlphaNode {
  beta?: BetaNode;
  gamma?: GammaNode;
  nodeOptions: Array<AlphaNode | BetaNode | GammaNode>;

  constructor(beta?: BetaNode, gamma?: GammaNode, nodeOptions: Array<AlphaNode | BetaNode | GammaNode> = []) {
    this.beta = beta
    this.gamma = gamma
    this.nodeOptions = nodeOptions
  }
}

class BetaNode extends PNode implements IBetaNode {
  ranking: number;

  constructor(ranking:number, classification?: Classification, qualityMetric?: number, nextPhase?: AlphaNode | BetaNode | GammaNode) {
    super(classification, qualityMetric, nextPhase);
    this.ranking = ranking;
  }
}

class GammaNode extends PNode implements IGammaNode {
  effectMetric: number;

  constructor(effectMetric:number, classification?: Classification, qualityMetric?: number, nextPhase?: AlphaNode | BetaNode | GammaNode) {
    super(classification, qualityMetric, nextPhase);
    this.effectMetric = effectMetric;
  }
}

With all of the required output class types defined, our expected final output type is a list of all the base node as defined below.

type FinalOutput = Array<AlphaNode | BetaNode | GammaNode>;

Defining input type from JSON structure

Though not strictly necessary, doing so helps tremendously with the aid of intellisense when implementing the recursive logic. For the sake of isolation, I defined these input types inside a namespace called Input.

The types of the input are, in large part, similar to the output types with a few major differences.

  1. All PNodes are nested inside objects with their respective node type as a key. This changes how the interfaces can be extended and thus the use of the & over top-level extends.

  2. Node key naming is different on alpha class interface (i.e. 'beta' instead of 'betaNode')

In addition to these structural differences I added the NodeTypeKeys enum to keep the usage of these keys consistent. And finally added the JSON type to represent the input JSON string type.

namespace Input {
  export enum NodeTypeKeys {
    Alpha = 'alphaNode',
    Beta = 'betaNode',
    Gamma = 'gammaNode',
  };

  export type IPNode = IAlphaNode | IBetaNode | IGammaNode;

  interface IPNodeBase {
    classification?: Classification;
    qualityMetric?: number;
    nextPhase?: IPNode;
  }

  export interface IAlphaNode {
    [NodeTypeKeys.Alpha]: { // node type as a key 
      [NodeTypeKeys.Beta]?: IBetaNode[NodeTypeKeys.Beta];
      [NodeTypeKeys.Gamma]?: IGammaNode[NodeTypeKeys.Gamma];
      nodeOptions?: Array<IPNode>; // not always present in JSON input
    }
  }

  export interface IBetaNode {
    [NodeTypeKeys.Beta]: { // node type as a key
      ranking: number;
    } & IPNodeBase
  }

  export interface IGammaNode {
    [NodeTypeKeys.Gamma]: { // node type as a key
      effectMetric: number;
    } & IPNodeBase
  }

  export interface JSON {
    pnodes: Array<IPNode>;
  }
}

Confirming JSON input

We can confirm these types on the provided JSON input using typescript with the JSON as a literal value. Doing so leads to yet another problem with you question… Two of the node types do not conform to your expected output node types 😔. I just altered the input JSON to conform to the types, if that is not what you expect I think you can figure out to alter my solution to you desired i/o. The JSON below is the new expected and corrected input, note the comments describing the changes.

{
  "pnodes": [
    {
      "betaNode": {
        "classification": "ADVERSE",
        "qualityMetric": 5,
        "ranking": 3,
        "nextPhase": {
          "gammaNode": {
            "classification": "MODEST",
            "effectMetric": 2.2
          }
        }
      }
    },
    {
      "alphaNode": { // you had this as gammaNode but `nodeOptions` only exists on the alphaNode
        "nodeOptions": [
          {
            "betaNode": {
              "classification": "EXTREME",
              "qualityMetric": 5,
              "ranking": 3,
              "nextPhase": {
                "alphaNode": {
                  "gammaNode": { // you had this as betaNode but `effectMetric` only exists on gammaNode
                    "classification": "ADVERSE",
                    "effectMetric": 1.3
                  }
                }
              }
            }
          }
        ]
      }
    }
  ]
}

See provided and altered inputs and type errors on TS Playground.


Solution

So without inputs and output fully and accurately defined, we can work on the transform logic.

The solution is broken up into several key helper functions. I will group them to make it easier to describe and understand.

Filter for nullish values

First is a simple one, this just a filter predicate that returns true whenever the parameter passed is truthy. This is really only needed as a formality of the implementation where I reuse the getClassFromNode function that needs to possibly return undefined.

function nonNullish<T>(n: T): n is NonNullable<T> {
  return Boolean(n);
}

Converters from JSON object nodes to classes

Second, we need a function for each node type, that takes the input JSON node object and converts it to its class structured form.

/**
 * Takes alpha JSON node and returns AlphaNode class
 */
const getAlphaClassFromNode = (node?: Input.IAlphaNode): AlphaNode | undefined => {
  if (!node) return;
  const { betaNode, gammaNode, nodeOptions = [] } = node[Input.NodeTypeKeys.Alpha];
  return new AlphaNode(
    betaNode && getBetaClassFromNode({ betaNode }),
    gammaNode && getGammaClassFromNode({ gammaNode }),
    nodeOptions.map(getClassFromNode).filter(nonNullish),
  );
};

/**
 * Takes beta JSON node and returns BetaNode class
 */
const getBetaClassFromNode = (node?: Input.IBetaNode): BetaNode | undefined => {
  if (!node) return;
  const { ranking, classification, qualityMetric, nextPhase } = node[Input.NodeTypeKeys.Beta];
  return new BetaNode(ranking, classification, qualityMetric, getClassFromNode(nextPhase));
};

/**
 * Takes gamma JSON node and returns GammaNode class
 */
const getGammaClassFromNode = (node?: Input.IGammaNode): GammaNode | undefined => {
  if (!node) return;
  const { effectMetric, classification, qualityMetric, nextPhase } = node[Input.NodeTypeKeys.Gamma];
  return new GammaNode(effectMetric, classification, qualityMetric, getClassFromNode(nextPhase));
};

The getClassFromNode will be defined later #recursion 😉

Type guard helpers

Since the node type of nextPhase, nodeOptions and the original pnodes can be any type of the three nodes, we need a type guard to identify which node we have in order to then convert it.

const isAlphaNode = (n: any): n is Input.IAlphaNode => Input.NodeTypeKeys.Alpha in n
const isBetaNode = (n: any): n is Input.IBetaNode => Input.NodeTypeKeys.Beta in n
const isGammaNode = (n: any): n is Input.IGammaNode => Input.NodeTypeKeys.Gamma in n

Note: This logic assumes the keys of the object are consistent. Meaning all a key of 'alphaNode' will always be an alpha node, or more precisely, alway defined by Inputs.IAlphaNode, same for beta and gamma nodes.


Convert any JSON node object to a class

Combining many the function helpers from above, we create a general function that we can pass any node type from the JSON object and get back the respective node class.

const getClassFromNode = (node: Input.IPNode | null = null): AlphaNode | BetaNode | GammaNode | undefined => {
  if (node === null) return;
  if (isAlphaNode(node)) {
    return getAlphaClassFromNode(node);
  } else if (isBetaNode(node)) {
    return getBetaClassFromNode(node);
  } else if (isGammaNode(node)) {
    return getGammaClassFromNode(node);
  } else {
    throw new Error('Unsupported node type found');
  }
};

Note: We throw and error if we encounter an unknown node type.


Root node recursion entry point

function parseNodes(rootNodes: Input.IPNode[] = []): FinalOutput {
  return rootNodes
    .map(getClassFromNode)
    .filter(nonNullish); // removes all empty node classes - only a formality of the reuse of the getClassFromNode function
}

Finally, calling parseNodes on the parsed JSON input, we are left with the final class structured output of the original JSON input.

const inputJSON = '<enter input json here>';
const input = JSON.parse(inputJSON) as Input.JSON;
const output = parseNodes(input.pnodes);

Explanation

So how does the logic work? First we start by looping over all the initial pnodes in the input JSON. Since we have a helper function that takes any type of node (alpha, beta, gamma) we can simply .map over each initial node and call getClassFromNode.

The getClassFromNode function takes the node and determines the node type using the key of the nested object, then calls the respective method to convert the JSON object the correct node class.

The getBetaClassFromNode and getGammaClassFromNode functions simply pass along the needed properties but the nextPhase expects a class type of the next node. Fortunately we already have getClassFromNode that can do just that. This will recurse until there are not more nodes in the tree.

Finally, the getAlphaClassFromNode function computes the beta using getBetaClassFromNode if betaNode property is defined, same for gammaNode using getGammaClassFromNode. The final step is to convert all the nodes within the nodeOptions array to their class structure, but we already did something similar with pnodes at the root level. That being just .map over all node objects with getClassFromNode and remove the undefined values with nonNullish function.

Note: Theoretically, none of the pnodes nor nodeOptions would return undefined from getAlphaClassFromNode but the types imply it’s possible, hence the .filter for nonNullish.

So all recursive logic requires a so-called exit case to stop the recursion, return the final result and prevent an infinite loop. So what is our edge case? Well there are two, eventually the tree will branches will end when either nodeOptions is empty (i.e. []) or nextPhase is undefined. The recursion stops as soon as either case is seen.

Because we only collapse the recursive stack once we reach the leaves, this approach is known as a depth-first search. Doing it this way allows us to instantiate the class once and not have to mutate the properties. Alternatively you could also do this with a breadth-first search approach by creating the top level classes before their descendent nodes and then add the descendent successively.


See complete solution on this TS Playground

Answered By – Nickofthyme

Answer Checked By – Timothy Miller (Easybugfix Admin)

Leave a Reply

(*) Required, Your email will not be published