Home > Net >  Infer correct generic type in Typescript
Infer correct generic type in Typescript

Time:01-12

I have the following method which serves to extend an existing key-value map — e.g. a result of a db query — with other object(s) of the same type.

export function extendWith<
  T extends { id: string | number },
  O =
    | (T["id"] extends string | number ? Record<T["id"], T> : never)
    | (T["id"] extends string | number ? Partial<Record<T["id"], T>> : never)
>(obj: O, vals: T | T[]): O {
  const extended = { ...obj };
  const values = Array.isArray(vals) ? vals : [vals];

  for (const val of values) {
    if (val !== undefined) {
      const prop = val["id"];

      if (
        typeof prop === "string" ||
        typeof prop === "number" ||
        typeof prop === "symbol"
      ) {
        (extended as any)[prop] =
          prop in obj ? { ...(obj as any)[prop], ...val } : val;
      }
    }
  }

  return extended;
}

When I call it as follows, all is well, i.e. I get a typescript error on the last line correctly stating that the type of name of the object I'm passing in is wrong.

interface Photo {
  id: number;
  name: string;
}
const photos: { [key: number]: Photo } = {
  1: { id: 1, name: "photo-1" },
  2: { id: 2, name: "photo-2" }
};
const extendedPhotos = extendWith<Photo>(photos, { id: 4, name: 3 });

Now, when I remove the explicit parameter <Photo> in the extendWith call on that last line, the typescript error disappears. I assume this has to do with typescript generic inference.

Does anyone know a way to achieve the inference being correct? Any tips to send me on the right path are much appreciated!

A sandbox to play with available here.

CodePudding user response:

Firstly, It looks like your first overload is unnecessary, as you're saying that when the id is a string or a number, the returned object is either Partial<O>, or a full O. O will always be valid when mapped to a Partial<O> type, so you can just say its type is Partial<O>.

With regards to the inference, if you let TypeScript infer the type it will use your input to infer the output of the function. What it seems you are asking for is that the function's second argument MUST be of type Photo, which isn't inference, and can't be inferred unless you pass it a variable that is of type Photo already. For TS to infer, you'd need to replace your last line with something like:

const myPhoto: Photo { id: 4, name: 'my-photo' };
const extendedPhotos = extendWith<Photo>(photos, myPhoto);

So that TypeScript can use the information from the input values to infer the output value.

CodePudding user response:

This can be done (though this example will need extending):

type Index = string | number;
type ObjectWithIdIndex = { id: Index };

function extendWith<O extends Record<Index, ObjectWithIdIndex>>(obj: O, v: O[keyof O]): O {
    (obj as Record<Index, ObjectWithIdIndex>)[v.id] = v;

    return obj;
}

The types of these parameters can be reduced to:

  • requiring that obj is an object with values that have id properties. We can constrain this by only accepting types that extend Record<Index, ObjectWithIdIndex>. So the following should give a type error:
extendWith(null, { id: 3, name: "photo-4" }); 
extendWith({ abc: 1 }, { id: 3, name: "photo-4" });
  • requiring that v is of the same type as of the values of obj. We can constrain this with O[keyof O]. As keyOf is a union of the properties of obj, O[keyof O] is a union of the values of these properties. The following should also give a type error:
interface Photo { id: number; name: string; }

const photos: Record<number, Photo> = {
  1: { id: 1, name: "photo-1" },
  2: { id: 2, name: "photo-2" }
};

extendWith(photos, { id: 4, name: "photo-4", abc: 2 });
extendWith(photos, { id: 4, name: 1 });
extendWith(photos, { name: "abc" });
extendWith(photos, null);

When calling extendWith, typescript will infer a more specific type for obj than Record<Index, ObjectWithIdIndex>. This has the consequence that:

  • We can use this type to infer the types of obj's values and then constrain v.
  • We can no longer assign new properties to obj as typescript doesn't know if obj is still an extensible Record type (eg. { a: 1 } is a subtype of Record<string, number>, but new properties can't be assigned to it). We can however cast obj back to its more generic type and then extend it.
  •  Tags:  
  • Related