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
}
}
}
}
}
]
}
}
]
}
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.
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;
}
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>;
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.
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
.
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>;
}
}
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.
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.
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);
}
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 😉
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 byInputs.IAlphaNode
, same for beta and gamma nodes.
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.
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);
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
nornodeOptions
would returnundefined
fromgetAlphaClassFromNode
but the types imply it's possible, hence the.filter
fornonNullish
.
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