简体   繁体   中英

How to add additional properties to a recursive type

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: { ... } }
} }

The type of a is exactly A , so when referencing a elsewhere, there is no guidance as to what the children of all the nodes within a are.

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

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

createA now solves the above limitation. One is now both provided with intellisense in how to create an A (as a parameter to createA ), and the output of the function will have intellisense about the children of all the nodes within a . For example:

const a = createA({ ... })
// (alias) createA<{ <-- The intellisense for a shows the children
//   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

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 all the children within all the nodes in the provided a , Ie:

const modifiedA = createModifiedA({ ... })
// (alias) createModifiedA<{ <-- Intellisense for modifiedA still shows all the children
//   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

Edit 1 (sno2 answer)

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

Edit 2

Improved wording.

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

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

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM