1

I have a tree-like data structure. I want to wrap each of the tree nodes with a node-wrapper, which adds additional functionalities to a node. But the same time I want to keep the typings intact. Can anybody help me out with that?

Here is a example showing what I want to achieve

const a = { children: [], value: 12 } as Node<number>;
const b = { children: [], value: 'string' } as Node<string>;
const c = { children: [b, a], value: true } as Node<boolean>;

const root = { children: [c]; value: null } as Node<null>;

// wrap node data with some functions, but keep typings stable
const wrappedNode = wrapNode(root);

wrappedNode.value; // type should be => null
wrappedNode.children[0].value; // type should be => boolean
wrappedNode.children[0].children[0].value; // type should be => string
wrappedNode.children[0].children[1].value; // type should be => number

My current Approach looks like:

interface Node<T, C extends Node<unknown, any[]>[]> {
  children: [...C];
  value: T;
}

interface WrapNode<T, C extends Node<unknown, any[]>[]> {
  children: WrapNode<any, [...C]>[];
  value: T;
  computeValue(): any;
}

function createNode<T, C extends Node<unknown, any[]>[]>(value: T, children: [...C]): Node<T, [...C]> {
  return {
    value,
    children,
  };
}

export function wrapNode<T, C extends Node<any, any>[]>(node: Node<T, C>): WrapNode<T, typeof node.children> {
  const value = node.value;

  return {
    ...node,
    computeValue: () => value,
    children: node.children.map(child => wrapNode(child)),
    //                          ^^^^^    ^^^^^^^^^^^^^^^
    //           types are:   C[number]  wrapNode<any, any>(n: Node<any, any>)
  };
}

const a = createNode(12, []);
const b = createNode('str', []);
const c = createNode(null, [b, a]);

const x = wrapNode(c);

x.value; // gives me type null, ok!
x.children[0].value; // gives me any :(
x.children[1].value; // gives me any too :(

Is it even possible, with TypeScript? I'm using TypeScript 4.0.2, if that helps. Thanks in advance :)

jlang
  • 929
  • 11
  • 32

1 Answers1

4

I think you might want your WrapNode interface to look like this:

interface WrapNode<T, C extends Node<unknown, any[]>[]> {
    children: { [K in keyof C]:
        C[K] extends Node<infer CT, infer CC> ? WrapNode<CT, CC> : never
    }
    value: T,
    computeValue(): any,
}

The important difference here is that the children property is a mapped array/tuple type that walks through the numeric indices of children and gets the type of each element.

There's not much chance the compiler can actually verify that any particular value is assignable to such a mapped type, so you'll probably want to use a type assertion inside your wrapNode() implementation:

function wrapNode<T, C extends Node<unknown, any[]>[]>(node: Node<T, C>): WrapNode<T, C> {
    const value = node.value;
    return {
        ...node,
        computeValue: () => value,
        children: node.children.map(wrapNode) as any, // need to assert here
    };
}

And then everything works as you want, I think:

const x = wrapNode(c);
x.children[0].value.toUpperCase(); // okay
x.children[0].value.toFixed(2); // error! string can't do that
x.children[1].value.toFixed(2); // okay
x.children[1].value.toUpperCase(); // error! number can't do that

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Going to test that tomorrow, great! Thanks a lot. :) i will accept it as soon i tested :) – jlang Sep 17 '20 at 21:13
  • Great, works fine :) Could you elaborate a bit on the "type assertion" part? Do you mean something like (in wrapNode's return): `return { /*...*/, children: node.children.filter(isNode).map(wrapNode)}`, where `isNode` has signature `isNode(value: any): value is Node`? – jlang Sep 18 '20 at 06:13
  • I have a follow up question: https://stackoverflow.com/questions/63952570/typescript-mapping-input-type-t-to-tree-like-type, if you don't mind :) – jlang Sep 18 '20 at 08:59
  • The type assertion is the `as any` in the `children` line. You can't fix it with `isNode()`: the problem isn't that `node.children` might not have `Node`s in it. The problem is that the compiler only knows that `node.childen.map(wrapNode)` will produce an array of `WrapNode`s. It doesn't understand that for each index `i`, if `node.children[i]` is of type `Node` then `node.children.map(wrapNode)[i]` will be of corresponding type `WrapNode`. That's just too high-order of reasoning for the compiler to perform. – jcalz Sep 18 '20 at 13:53