Home > Blockchain >  Get property of generic object using keyof
Get property of generic object using keyof

Time:05-14

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

Playground link to code

  • Related