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