Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript: generic type restriction on return value of keyof

Tags:

typescript

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.

like image 587
Yoni Gibbs Avatar asked Jun 12 '19 08:06

Yoni Gibbs


People also ask

What does Keyof return?

keyof T returns a union of string literal types. The extends keyword is used to apply constraints to K, so that K is one of the string literal types only. extends means “is assignable” instead of “inherits”' K extends keyof T means that any value of type K can be assigned to the string literal union types.

How do you define a generic type in TypeScript?

Generics allow creating 'type variables' which can be used to create classes, functions & type aliases that don't need to explicitly define the types that they use. Generics makes it easier to write reusable code.

How do I pass a TypeScript generic type?

Assigning Generic ParametersBy passing in the type with the <number> code, you are explicitly letting TypeScript know that you want the generic type parameter T of the identity function to be of type number . This will enforce the number type as the argument and the return value.

What is a generic type parameter?

Generic Methods A type parameter, also known as a type variable, is an identifier that specifies a generic type name. The type parameters can be used to declare the return type and act as placeholders for the types of the arguments passed to the generic method, which are known as actual type arguments.


1 Answers

You can easily fix this by changing keyObj.toString() call to String(keyObj) which will internally call .toString() on whatever you pass it so behavior will stay the same except that it won't blow up on undefined and null. In fact you can replace the whole line:

const key = (typeof keyObj === "string") ? keyObj : keyObj.toString()

with

const key = String(keyObj)

because if it is a string it will just not do anything.

UPDATE:

You almost had the correct type safe solution, you just need an extra constraint on T:

function convertArrayToObject<
  T extends { [Key in K]: string }, // This is required for the reduce to work
  K extends PropertiesOfType<T, string>
>(array: Array<T>, indexKey: K): ItemsMap<T> {
  return array.reduce((accumulator: ItemsMap<T>, current: T) => {
    accumulator[current[indexKey]] = current
    return accumulator
  }, {})
}
like image 141
Dmitriy Avatar answered Sep 28 '22 08:09

Dmitriy