I am trying to create a generic TypeScript function that recursively looks through a tree structure to find an item with a specific id value. In order to make it reusable I'm trying to use generics and you just give the function the top level, the id value, and the names of the properties to use for id and children. Here is my example class:
export class Thing{
name: string;
id: string;
children: Thing[];
constructor(){
this.name='';
this.id='';
this.children=[];
}
}
And the method to find the item by id:
export function GetGenericItemById<T>(
allItems: T[],
idField: keyof T,
childrenField: keyof T,
id: string
): any {
const item = allItems.find((x) => x[idField] === id);
if (item != null) {
return item;
}
const subItems = allItems.flatMap((i) => i[childrenField]);
if (subItems.length === 0) {
return undefined;
}
return GetGenericItemById(subItems, idField, childrenField, id);
}
However, on the find function where it makes the comparison x[idField] === id
it tells me This condition will always return 'false' since the types 'T[keyof T]' and 'string' have no overlap.
. The intent here is to get that property from the object and get it's value. What am I doing wrong here?
I did do this without generics like this:
export function GetGenericItemById(
allItems: any[],
idField: string,
childrenField: string,
id: string
): any {
const item = allItems.find((x) => x[idField] === id);
if (item != null) {
return item;
}
const subItems = allItems.flatMap((i) => i[childrenField]);
if (subItems.length === 0) {
return undefined;
}
return GetGenericItemById(subItems, idField, childrenField, id);
}
This works, however you can also put whatever you want into the idField
and childrenField
property so you could mess it up pretty easily. It would be nice to restrict those to only be valid keys of the type you are using. I thought my attempt above was doing that but it doesn't seem to be the same since it's giving that error.
EDIT
Per request here is a simple example of how I intent to use it. Say I have a simple data structure that has 'things'. Each Thing
has an id and a name as well as potential children that are other Thing
s. Like this:
const data=[
{
name: "thing 1",
id: "1",
children: [
{
name: "thing 1a",
id: "1a",
children: []
}
]
},
{
name: "thing 2",
id: "2",
children: [
{
name: "thing 2a",
id: "2a",
children: []
}
]
}
];
My intent would be to call the function something like this:
const foundThing = GetGenericItemById<Thing>(topThings, 'id', 'children', '1a');
In this example topThings
would be a collection that just contained the top level items (ids 1 and 2 in the sample data). Basically it just searches down the tree to find that item. I would expect foundThing
to be the Thing
object with id '1a'.
EDIT
You also really shouldn't have to put the <Thing>
portion in since it will be implied by the first argument but I'm leaving it in there for clarity.
CodePudding user response:
The problem is that the compiler has no idea that the property at the idField
key of an object of type T
is string
-valued, so it won't let you compare it to a string. Additionally it has no idea that the property at the childrenField
key of an object of type T
is array-of-T
-valued, so it won't let you just call GetGenericItemById
on it.
In order to convey this to the compiler, you will need to make your function generic in both the type of idField
, call it IK
, and the type of the childrenField
, call it CK
. Then we want to constrain T
to be an object type with a string
property at key IK
and a T[]
property at key CK
.
One way to write that type is
Record<IK, string> &
Record<CK, T[]> &
Record<string, any>
This uses the Record<K, V>
utility type to represent "an object with keys of type K
and values of type V
], and intersections to combine the constraints together. You can think of that as "an object with a string
property at key IK
, and a T[]
property at key CK
, and any property at any string
keys. The last bit with Record<string, any>
is not necessary, but it lowers the chance that the allItems
parameter will be rejected for having excess properties.
Here's the function now with no errors:
function GetGenericItemById<
IK extends string,
CK extends string,
T extends Record<string, any> & Record<IK, string> & Record<CK, T[]>
>(
allItems: T[],
idField: IK,
childrenField: CK,
id: string
): T | undefined {
const item = allItems.find((x) => x[idField] === id); // okay
if (item != null) {
return item;
}
const subItems = allItems.flatMap((i) => i[childrenField]);
if (subItems.length === 0) {
return undefined;
}
return GetGenericItemById(subItems, idField, childrenField, id); // okay
}
Those errors are gone because now the compiler knows that x[idField]
is a string
and that subItems
is a T[]
.
The return type has been changed from any
to T | undefined
, so calls to GetGenericItemById
will have strongly typed results. Let's test it:
GetGenericItemById(
topThings,
'id',
'children',
'1a'
)?.name.toUpperCase(); // THING 1A
Looks good; the compiler accepts the call, and the return value is of type Thing | undefined
.