1

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, I.e.:

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.

samhuk
  • 109
  • 1
  • 8
  • Looks like you have a typo in `A` where `id` is `string` but I guess it's supposed to be `number`. And should `children` be optional instead of required? Could you make sure the code here is a [mre]? – jcalz Mar 14 '22 at 01:01
  • Does [this approach](https://tsplay.dev/mMxqrN) meet your needs? If so I can write up an answer; if not, what am I missing? Note, I hope before I answer you can [edit] your example so that the value of `a` really is an `A`. So `id` should be `string` or `number`, and `children` should either be optional or `a` should include `children` everywhere, even in that nested `fizz` property. – jcalz Mar 14 '22 at 01:20
  • 1
    Great, thank you. So does [this approach](https://tsplay.dev/mMxqrN) work for you? Let me know one way or the other so I only spend time writing up an answer if it actually addresses your issue. – jcalz Mar 14 '22 at 01:47
  • @jcalz That works! Thank you so much! This was completely wracking my brains, and I suppose I can see why. The required typing that I can see one ends up needing seems quite complex. – samhuk Mar 14 '22 at 01:50

2 Answers2

3

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 want 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

peterjwest
  • 4,294
  • 2
  • 33
  • 46
jcalz
  • 264,269
  • 27
  • 359
  • 360
0

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

sno2
  • 3,274
  • 11
  • 37
  • I have updated the question with some feedback. This was one of my attempts, but it doesn't achieve the desired result. Apologies if I wasn't clear enough! – samhuk Mar 14 '22 at 01:46