Upgrading to TypeScript 3.5 has caused some code of mine to no longer compile. I think this is because of a breaking change: Generic type parameters are implicitly constrained to unknown. I'm trying to find the best way to fix the code now.
TL;DR: how do I declare a generic function with types T and K extends keyof T, but where T[K] must be a string.
Longer version: I want to convert an array of objects to a single object which has all the values in the array, keyed on some property of the objects. For example:
type Person = { id: string, firstName: string, lastName: string, age: number}
const array: Person[] = [
{id: "a", firstName: "John", lastName: "Smith", age: 27},
{id: "b", firstName: "Bill", lastName: "Brown", age: 53}
]
const obj = convertArrayToObject(array, "id")
The result of this would be that obj would have this structure:
{
a: {id: "a", firstName: "John", lastName: "Smith", age: 27},
b: {id: "b", firstName: "Bill", lastName: "Brown", age: 53}
}
I had this function to do this:
type ItemsMap<T> = { [key: string]: T }
function convertArrayToObject<T>(array: Array<T>, indexKey: keyof T): ItemsMap<T> {
return array.reduce((accumulator: ItemsMap<T>, current: T) => {
const keyObj = current[indexKey]
const key = (typeof keyObj === "string") ? keyObj : keyObj.toString()
accumulator[key] = current
return accumulator
}, {})
}
Since upgrading to TypeScript 3.5, the error is on the call to toString: Property toString does not exist on type T[keyof T].
I can understand the problem: since that breaking change in TypeScript 3.5 the return value of current[indexKey] is now unknown rather than an object, so toString can't be called on it. But how do I get round this?
Ideally what I'd like to do is put a generic constraint on the type of the indexKey parameter such that you can only pass in a key whose return value is itself a string. Here's what I've managed to get so far (though I'm not sure if it's the best approach):
First, I declare a type which is used to find all properties of a given type, TObj, which return a given type of result, TResult:
type PropertiesOfType<TObj, TResult> =
{ [K in keyof TObj]: TObj[K] extends TResult ? K : never }[keyof TObj]
So for example I could now get all the string properties of Person:
type PersonStringProps = PropertiesOfType<Person, string> // "firstName" | "lastName" | "id"
And now I can declare the function as follows:
function convertArrayToObject<T, K extends PropertiesOfType<T, string>>(
array: Array<T>, indexKey: K): ItemsMap<T> { ...
I can now only call the function with properties that return string, for exampe:
convertArrayToObject(array, "id") // Compiles, which is correct
convertArrayToObject(array, "age") // Doesn't compile, which is correct
However, in the function body, I still can't seem to use the passed in keyof T and have the compiler know that the value is returned is a string:
return array.reduce((accumulator: ItemsMap<T>, current: T) => {
const key: string = current[indexKey]
This doesn't compile: Type T[K] is not assignable to type string. I can get round this by casting:
const key: string = current[indexKey] as unknown as string
And I guess that's safe as I know that current[IndexKey] is a string. But it still doesn't seem quite right.