Home > Software engineering >  How to add additional properties to a recursive type
How to add additional properties to a recursive type

Time:03-14

Suppose one has a recursive type, A:

type A = {
  id: number
  children?: { [k: string] : A }
}

So an example of A would be:

const a: A = { id: 1, children: {
  foo: { id: 2, children: { 
    fizz: { id: 4 }
  } },
  bar: { id: 3, children: { ... } }
} }

However, the type of a is A, so when referencing a elsewhere, there is no guidance as to what the structure of a is (what children there are).

To solve this, one can write a function that creates an object of A. It simply returns the provided A:

const createA = <T extends A>(a: T): T => a

Now, if one uses createA, the above issue is solved. You are provided with guidance in creating an A (as a parameter to createA), and the output of the function will have information about the structure of a:

const a = createA({ ... })
// (alias) createA<{
//   id: number
//   children: {
//     foo: {
//       id: number
//       children: { ... }
//     }
//     ...
//   }
// }>)

const childNode = a.children.notFoo // <-- Fails linting, since a.children.notFoo is invalid

const anotherChildNode = a.childen. // <-- Intellisense shows 'foo' and 'bar' as available

The crux here, is say we modify createA to add on a property, say path, to each node in the provided 'a' (the implementation is irrelevant), resulting in the following output:

    const createModifiedA = (a: A) => { ... } 
    // { id: 1, path: '', children: {
    //   foo: { id: 2, path: '/foo', children: { 
    //     fizz: { id: 4, path: '/foo/fizz' }
    //   } },
    //   bar: { id: 3, path: '/bar', children: { ... } }
    // } }

I am wondering if it is possible, and if so, how, one would achieve the same end result as createA but for createModifiedA, keeping the intellisense for the child properties. I.e.:

const modifiedA = createModifiedA({ ... })
// (alias) createModifiedA<{
//   id: number
//   path: string
//   children: {
//     foo: {
//       id: number
//       path: string
//       children: { ... }
//     }
//     ...
//   }
// }>)

const childNode = a.children.notFoo // <-- Fails linting, since a.children.notFoo is invalid

const anotherChildNode = a.childen. // <-- Intellisense *still* shows 'foo' and 'bar' as available

In plain english, that would be "this function returns an object that is just like the provided a, but each node has an additional property".

Edit 1 (sno2 answer)

Clarification: modifiedA should have the intellisense just like createaA that shows the available children at each node.

CodePudding user response:

So createModifiedA will take a value of generic type T which is constrained to A, and return a value of type ModifiedA<T> for some suitable definition of ModifiedA<T>:

declare const createModifiedA:
  <T extends A>(a: T) => ModifiedA<T>;

We wont ModifiedA<T> to add a string-valued path property to T and recursively to all the subproperties of T's children. Let's use the name ModifiedAProps<T> to refer to this recursive operation we want to apply to children. Then ModifiedA<T> looks like:

type ModifiedA<T extends A> = { path: string } & { [K in keyof T]:
  K extends "children" ? ModifiedAProps<T[K]> : T[K]
}

You can see that we intersect {path: string} with a mapped type. That means ModifiedA<T> will definitely have a path property of type string. And it will also have a property for every key that's in T. If the property key's name is "children", then we want to operate on it recursively with ModifiedAPros. Otherwise we want to leave it alone.

So now we can define ModifiedAProps<T> like this:

type ModifiedAProps<T> = { [K in keyof T]:
  T[K] extends A ? ModifiedA<T[K]> : T[K]
}

Here we are just making another mapped type where each property is mapped with ModifiedA if that property is of type A, and left alone otherwise.


Okay, let's test it out:

const a = createModifiedA({
  id: 1, children: {
    foo: {
      id: 2, children: {
        fizz: { id: 4 }
      }
    },
    bar: { id: 3, children: {} }
  }
});

/* const a: ModifiedA<{
    id: number;
    children: {
        foo: {
            id: number;
            children: {
                fizz: {
                    id: number;
                };
            };
        };
        bar: {
            id: number;
            children: {};
        };
    };
}> */

Hmm, that type is ModifiedA<T> for the proper T, but it's not obvious that it evaluates to what we want. Let's convince the compiler to expand the type definition out fully (see this SO question and its answer for how this is implemented):

type X = ExpandRecursively<typeof a>;
/* type X = {
    path: string;
    id: number;
    children: {
        foo: {
            path: string;
            id: number;
            children: {
                fizz: {
                    path: string;
                    id: number;
                };
            };
        };
        bar: {
            path: string;
            id: number;
            children: {};
        };
    };
} */

Okay, great. That looks exactly like the type of the object passed into createModifiedA except that every A-like value also has a path: string property in it. And so the compiler knows the exact shape of a:

a.id // number
a.path // string
a.children.foo.children.fizz.path // string
a.children.baz.children // error, Property 'baz' does not exist on type

Playground link to code

CodePudding user response:

You can do this by creating a new recursive type alias and intersecting to override the children to match the clauses you want:

type A = {
  id: string
  children: { [k: string] : A }
}

const createA = <T extends A>(a: T): T => a

type ModifiedA = A & { children: Record<string, ModifiedA & { path: string; }> }

const createModifiedA = (a: ModifiedA) => a;

const foo = createModifiedA({
    id: "asdf",
    children: {
        fizz: { id: "asdf", path: "ad", children: {} },
        bizz: { id: "asdf", path: "ad", children: {} },
        lizz: {
            id: "asdf",
            path: "ad",
            children: {
                mizz: {
                    id: "asdf2",
                    path: "hey",
                    children: {},
                }
            }
        },
    }
});

foo.children.fizz.path; // no error

TypeScript Playground Link

  • Related