Reputation: 109
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.
Upvotes: 1
Views: 640
Reputation: 329248
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
Upvotes: 3
Reputation: 4213
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
Upvotes: 0