0
votes

Playground link here

I want to define a function searchText that takes an array of objects that needs to be filtered, array of object properties that will be searched through and a string value that will be searched for. In the end I want something like this to be possible:

type User = { firstName: string, lastName: string, age: number}
let users: User[] = [
    { firstName: 'John', lastName: 'Smith', age: 22},
    { firstName: 'Ted', lastName: 'Johnson', age: 32}
]
searchText(users, ['firstName', 'lastName'], 'john') // should find both entries

I want to constrain this function to only accept valid arrays of property names. A valid array should only contain properties that have a type of string since the function is searching for text. In another SO question I found a way to define type that should only allow valid values (https://stackoverflow.com/a/54520829/1242967). This type is defined as

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

With this type I am able to define a function that allows specifying the 'valid array constraint'

function searchTextInUsers(users: User[], stringFields: KeysMatching<User, string>[], text: string): User[]{
    return users.filter(user =>
        stringFields.some(field => user[field].toLowerCase().includes(text.toLowerCase()))
    );
}

This compiles and works as expected. Trying to pass a non-string property causes a compilation error

searchTextInUsers(users, ['firstName', 'lastName', 'age'], 'john') // ERROR: Type 'string' is not assignable to type '"firstName" | "lastName"'.

But what I actually wanted to do is to write a function that would work for any type, not just User but surprisingly just adding a generic type parameter did not work.

function searchText<T>(elements: T[], stringFields: KeysMatching<T, string>[], text: string): T[]{
    return elements.filter(element => 
        stringFields.some(field => element[field].toLowerCase().includes(text.toLowerCase())) // Error on this line
    );
}

Compiler shows the following error

Property 'toLowerCase' does not exist on type 'T[{ [K in keyof T]: T[K] extends string ? K : never; }[keyof T]]'

Could someone explain why this did not work and why typescript does not narrow the type down to a string in this case? Is there another way to achieve what I want to do when using generic types? Playground link here

1

1 Answers

0
votes

Put simply, typescript isn't as smart as you want it to be in this case.

In your searchTextInUsers function, it knows that type K = KeysMatching<User, string> can only be "firstName" | "lastName" and sees that both of these keys return string properties from User. But it's not able to figure that out from the KeysMatching type alone.

Instead of working backwards, use a generic that allows typescript to move forwards.

function searchText<K extends keyof any, T extends Record<K, string>>(elements: T[], stringFields: K[], text: string): T[]{
    return elements.filter(element => 
        stringFields.some(field => element[field].toLowerCase().includes(text.toLowerCase()))
    );
}

Here we say the type K represents the string property keys that we are passing in as stringFields. Our elements must have all of these keys, and we know that the corresponding values are strings, so we say that T must extend Record<K, string>.

This works as expected. searchText(users, ['firstName', 'lastName'], 'john') is fine. But if we try to pass in a property name that is not a string property, like 'age', we'll get an error because the data array users doesn't fulfill the contract {age: string}.

searchText(users, ['firstName', 'age'], 'john')) gives this error:

Argument of type '{ firstName: string; lastName: string; age: number; }[]' is not assignable to parameter of type 'Record<"firstName" | "age", string>[]'.
  Type '{ firstName: string; lastName: string; age: number; }' is not assignable to type 'Record<"firstName" | "age", string>'.
    Types of property 'age' are incompatible.
      Type 'number' is not assignable to type 'string'.

TS Playground Link