Home > Mobile >  Enforce argument to be a keyof other property that extends (or partially) a type
Enforce argument to be a keyof other property that extends (or partially) a type

Time:12-20

I have a function called updateMany which accepts a generic type of an entity and allows to update certain data based on the entity and a "by" property which should be a keyof data. Something like this:

function updateMany<Table>() {
  return <
    Data extends { [column in keyof Table]?: Table[column] },
    By extends keyof Data
  >(params: {
    by: By;
    data: Table[];
  }) => {};
}

(I wrapped the function inside another function since I wasn't sure how to achieve it in a single function)

Now, everything seems to be working, except the fact that data has to have all of the properties. When I try to wrap it inside Partial, then I can pass to by properties that doesn't exists in data. I know that my implementation is incorrect, but this is as far as I could get it to what I want.

Play in playground

CodePudding user response:

My suggestion here would be to write updateMany() like this:

function updateMany<T>() {
  return <
    K extends keyof T,
    P extends K
  >(params: {
    by: P;
    data: (Pick<T, K> & Partial<T>)[];
  }) => { };
}

Both my version and yours use currying to work around the lack of "partial type parameter inference" (as requested in microsoft/TypeScript#26242), since there's no way to call a function with multiple type parameters like T, K, and P and have the caller manually specify T while letting the compiler infer K and P. Currently in TypeScript it's all or nothing; you can either manually specify all three, or let the compiler infer all three. Currying sidesteps this by having one function where you specify T return another function where the compiler infers K and P.

Anyway, K corresponds to the keys present on the elements of data, and P corresponds to the key specified in by. I do this with two type parameters instead of just K for both, since we don't want the compiler to infer that K is the union of the key from by and the ones in data. Instead we constrain P extends K so that whatever K is inferred from data, we require the key in by to be in that set.

Conceptually we would have data be of type Pick<T, K>[] and that would be it. But since you said you wanted some better IntelliSense autocompletion for data, I've intersected Pick<T, K> with Partial<T>. Luckily the compiler seems to use Pick<T, K> to infer K to be only those keys actually present in data entries, while also using Partial<T> to provide hints for autocompletion.

Let's see if it works:

const updateManyEntities = updateMany<Entity>();;

const X1 = updateManyEntities({ by: "A", data: [{ A: 1 }] }); // okay
const X2 = updateManyEntities({ by: "A", data: [{ A: 1, B: 2, C: 2 }] }); // okay
const X4 = updateManyEntities({ by: "C", data: [{ A: 1, B: 2 }] }); // error!
// ---------------------------> ~~
// Type '"C"' is not assignable to type '"A" | "B"'
const X3 = updateManyEntities({ by: "D", data: [{ A: 1, B: 2, C: 2 }] }); // error!
// ---------------------------> ~~
// Type '"D"' is not assignable to type '"A" | "B" | "C"'
const X5 = updateManyEntities({ by: "D", data: [{ A: 1, B: 2, C: 3, D: 4 }] }); // error!
// ---------------------------> ~~                            ----> ~~~~
// Type '"D"' is not assignable to type 'keyof Entity'        |
// Object literal may only specify known properties, and 'D' does not exist in type...

Looks good!

Playground link to code

  • Related