Home > database >  How to type a function that accepts an array of objects with a specific type for value of key K
How to type a function that accepts an array of objects with a specific type for value of key K

Time:11-21

I need to type a signature of a generic function that takes in input an array of objects and 3 column names of this object:

function myFunction<T, K extends keyof T>(
  dataset: T[],
  propertyOne: K,
  propertyTwo: K,
  propertyThird: K
): {
  ...
}

This works but I need now to be more specific about the type of the value of column propertyTwo. In fact thise value can be only string.

So the datum of type T is something like this:

T {
  [propertyOne]: any
  [propertyTwo]: string
  [propertyThird]: any
  [x?: string]: any
}

Example:

const dataset = [{age: 23, name: 'josh', country: 'america'}, ...]
myFunction(dataset, 'age', 'name', 'country')

How can I do that?

I tried this:

function myFunction<T, K extends keyof T, C extends string>(
  dataset: T[],
  propertyOne: K,
  propertyTwo: K, // how can I use C?
  propertyThird: K
): {
  ...
}

CodePudding user response:

First, if you have different requirements or intents for what you do with propertyOne, propertyTwo, and propertyThreeThird, then they should probably each have their own generic type parameters. Otherwise the compiler will just infer your single type argument to be a union of the three literal types, and there'd be no way to figure out which member corresponds to propertyTwo. So we can refactor to:

function myFunction<
  T,
  K1 extends keyof T,
  K2 extends keyof T,
  K3 extends keyof T
>(
  dataset: T[],
  propertyOne: K1,
  propertyTwo: K2,
  propertyThird: K3
) {
  
}

Now we can represent the constraint where the property type of T at key K2 must be string. The easy way to do this is to add a recursive constraint on T, by saying that it must be assignable to Record<K2, string> (using the Record<K, V> utility type to represent a type with keys K and values V):

function myFunction<
  T extends Record<K2, string>, // add constraint
  K1 extends keyof T,
  K2 extends keyof T,
  K3 extends keyof T
>(
  dataset: T[],
  propertyOne: K1,
  propertyTwo: K2,
  propertyThird: K3
) {
  dataset.forEach(x => x[propertyTwo].toUpperCase()); // okay
}
const dataset = [{ age: 23, name: 'josh', country: 'america' }]

const okay = myFunction(dataset, 'age', 'name', 'country'); // okay
const bad = myFunction(dataset, 'name', 'age', 'country'); // error!
// ------------------> ~~~~~~~

This works as desired. Inside the implementation of myFunction, the compiler knows that each element of dataset has a string-valued property at the key propertyTwo.

And callers are also given an error if the argument passed for propertyTwo doesn't correspond to a string property on the elements of dataset. Hooray!


The only issue is that callers probably would rather see the error on 'age' and not on dataset, and would also like reasonable IntelliSense so that they are prompted with only the key names corresponding to string properties. If you want that, you can modify the constraint on K2.

First we need a utility type KeysMatching<T, V> which computes the keys of T whose properties are assignable to V. There's no built-in utility or functionality that works this way (there is a request at microsoft/TypeScript#48992 for a native version of this that the compiler understands well) so we need to build it. Here's one way to build it:

type KeysMatching<T extends object, V> = keyof {
  [K in keyof T as T[K] extends V ? K : never]: any
};

What I'm doing here is using key remapping in mapped types to map T to a new type which only has keys K where T[K] extends V. So if T is {age: number, name: string, country: string} and V is string, then that mapped type is {name: string, country: string}. And then we get its keys with the keyof operator, so that would be "name" | "country".

And now instead of K2 extends keyof T, we write K2 extends KeysMatching<T, string>:

function myFunction<
  T extends Record<K2, string>,
  K1 extends keyof T,
  K2 extends KeysMatching<T, string>,
  K3 extends keyof T
>(
  dataset: T[],
  propertyOne: K1,
  propertyTwo: K2,
  propertyThird: K3
) {
  dataset.forEach(x => x[propertyTwo].toUpperCase());
}
const dataset = [{ age: 23, name: 'josh', country: 'america' }]

const okay = myFunction(dataset, 'age', 'name', 'country');
const bad = myFunction(dataset, 'name', 'age', 'country'); // error!
// -----------------------------------> ~~~~~

There you go. Now the constraint is enforced and you get the error where you want it!


It might seem redundant that T is constrained to Record<K2, string> and K2 is constrained to KeysMatching<T, string> (they are, conceptually, the same constraint), but unfortunately the compiler cannot understand what K2 extends KeysMatching<T, string> implies inside the implementation of myFunction()... so if you remove the T extends Record<K2, string> constraint, you get this:

function myFunction<
  T,
  K1 extends keyof T,
  K2 extends KeysMatching<T, string>,
  K3 extends keyof T
>(
  dataset: T[],
  propertyOne: K1,
  propertyTwo: K2,
  propertyThird: K3
) {
  dataset.forEach(x => x[propertyTwo].toUpperCase()); // error!
  // -------------------------------> ~~~~~~~~~~~
  // Property 'toUpperCase' does not exist on type 'T[K2]'
} 

This is why microsoft/TypeScript#48992 exists; if a native KeysMatching existed, presumably you could write just K2 extends KeysMatching<T, string> and the compiler would still understand that T[K2] must be assignable to string. But this doesn't exist, so the redundant constraint is useful.

Playground link to code

  • Related