Using TypeScript to specify complex tree processing rules

Viewed 439

I am trying to adapt an existing process that I have already written in TypeScript to be more strongly typed.

Basically, I have a tree structure that looks like this:

interface Node {
    [name: string]: Leaf | Node | Node[] | undefined;
}

It is a simple tree where each node contains either a leaf node, another node, a list of nodes, or undefined, organized by property names.

I have a mechanism where rules can be run on this tree. The rules look like this:

interface Rule<T extends Node, K = keyof T> {
    name: K;
    optional?: boolean;
    data?: {}; // matches against the structure of a child Leaf
    list?: boolean; // whether this child should be a list
    process?(processor: RuleProcessor): Node; // for processing sub-nodes
    // ...other flags and stuff specific to the rule
}

I would like to make this more generic by specifying separate types of rules that apply to the different types of children:

  • optional only applies to children that may be undefined
  • data only applies to children that are leaves
  • list only applies to children that are lists (in which case it will always be true)
  • process() only applies to children that are nodes or lists of nodes

This is what I'm envisioning:

type Child = Leaf | Node | Node[];

interface Node {
    [name: string]: Child | undefined;
}

interface Rule<T extends Node, K extends keyof T = keyof T, _V extends T[K] = T[K]> {
    name: K;
}

interface OptionalRule<T extends Node, K extends keyof T = keyof T> extends Rule<T, K, Child | undefined> {
    optional: true;
}

interface RequiredRule<T extends Node, K extends keyof T = keyof T, V extends Child = Child> extends Rule<T, K, V> {}

interface LeafRule<T extends Node, K extends keyof T = keyof T> extends RequiredRule<T, K, Leaf> {
    data: {};
}

interface ListRule<T extends Node, K extends keyof T = keyof T> extends RequiredRule<T, K, Node[]> {
    list: true;
}

interface ProcessRule<T extends Node, K extends keyof T = keyof T> extends RequiredRule<T, K, Node | Node[]> {
    process(processor: RuleProcessor): Node;
}

type AnyRule<T extends Node> = OptionalRule<T> | LeafRule<T> | ListRule<T> | ProcessRule<T>;

function process<T extends Node>(node: T, rules: AnyRule<T>[]) {
    // logic
}

The idea is that if I specify a rule for a specific property, the chosen rule type would would check against the type of the property.

For example, say I have a node:

const node: Node = { a: new Leaf(), b: [] };

And I process it:

process(node, [{ name: 'a', data: {} }, { b: list: true }]);

I would expect that specifying data would check somehow to make sure that node['a'] is a Leaf, and give an error if it wasn't. Likewise, the second rule would check to make sure that node['b'] is a Node[].

However, when I change data: {} to list: true on a, it gives no error, even though node['a'] is not an array. I knew I was doing something wrong from the start, but I'd like to know if something like this is even possible in TypeScript. I know that with a lot of these new features like keyof T and T[K] we are capable of describing some pretty complicated types, so I'd like to be able to achieve what I'm trying to do.

My goal is to put compile-time checks in place so that the developer can't make a mistake. Obviously none of this needs to apply at runtime because types are all stripped away.

EDIT: Basically this problem would be solved if there was a way for me to put a constraint on V = T[K] that V must be a specific type.

1 Answers

So, it turns out that this is currently solvable with mapped types! And there is no need to define the Node type:

type Child = Leaf | Node | Node[];

interface Rule<T extends { [P in K]: V }, K extends keyof T, V> {
    name: K;
}

interface OptionalRule<T extends { [P in K]?: V }, K extends keyof T, V> extends Rule<T, K, Child | undefined> {
    optional: true;
}

interface RequiredRule<T extends { [P in K]: V }, K extends keyof T, V extends Child> extends Rule<T, K, V> {}

interface LeafRule<T extends { [P in K]: Leaf }, K extends keyof T> extends RequiredRule<T, K, Leaf> {
    data: {};
}

interface ListRule<T extends { [P in K]: V[] }, K extends keyof T, V> extends RequiredRule<T, K, V[]> {
    list: true;
}

interface ProcessRule<T extends { [P in K]: V | V[] }, K extends keyof T, V> extends RequiredRule<T, K, V | V[]> {
    process(processor: RuleProcessor): V;
}

type AnyRule<T extends { [P in K]: V }, K extends keyof T, V> = OptionalRule<T, K, V> | LeafRule<T, K> | ListRule<T, K, V> | ProcessRule<T, K, V>;

function process<T extends { [P in K]: V }, K extends keyof T, V>(node: T, rules: AnyRule<T, K, V>[]) {
    // logic
}

A bit verbose, but it works fine! Plus function type parameters can be inferred.

Related