How to extend a keyof type so that it includes modified versions of the keys, e.g. prefixed with '-'?

Viewed 3675

For example, I'd like to add Typescript type safety to the vanilla Javascript Sort array of objects by string property value solution. It accepts as args keys of the object to be sorted, to determine which key to sort by. If the key is prefixed with -, the sort is reversed.

How would I do type the arg to accept, for example, both "age" and "-age"?

This is my attempt:

export function dynamicSortMultiple<T extends object, U extends keyof T>(
  props: Array<U>,
) {
  function dynamicSort(key: U) {
    let sortOrder = 1
    if (typeof key === 'string' && key.startsWith('-')) {
      sortOrder = -1
    }
    return function (a: T, b: T) {
      const result = a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0

      return result * sortOrder
    }
  }

  return function (obj1: T, obj2: T) {
    let i = 0
    let result = 0
    const numberOfProperties = props?.length
    while (result === 0 && i < numberOfProperties) {
      result = dynamicSort(props[i])(obj1, obj2)
      i++
    }

    return result
  }
}

export interface User {
  userId: string
  firstName: string
  lastName: string
  login: string
  password: string
  rank: number
  score: number
  age?: number
}

dynamicSortMultiple<User, keyof User>(['firstName', 'score', '-age'])

typescript playground

On the last line I see the error

Type '"-age"' is not assignable to type 'keyof User'.

Is there an any way to extend keyof User properly so that values prefixed with '-' are still considered valid values for the type?

I'll appreciate any solution even if you replace mine entirely.

1 Answers

My changes:

  1. the T and U generic params were redundant. Just need T.

    Note: I originally just replaced all your U references with keyof T, but then pulled it out to sortArg to facilitate #2.

  2. use Template Literal Types introduced in TS 4.1

  3. You forgot to trim the - prefix in the startsWith('-') case

  4. Use type assertions where Typescript isn't able to narrow the type to what it must be given the logic flow (The TS team is always improving the TS compiler's flow analysis, so I'll bet one day this will be automatic)

  5. API improvements: renamed the function and added a convenient sort function that reads better in code (see example usage that follows the solution code).

type sortArg<T> = keyof T | `-${string & keyof T}`

/**
 * Returns a comparator for objects of type T that can be used by sort
 * functions, were T objects are compared by the specified T properties.
 *
 * @param sortBy - the names of the properties to sort by, in precedence order.
 *                 Prefix any name with `-` to sort it in descending order.
 */
export function byPropertiesOf<T extends object> (sortBy: Array<sortArg<T>>) {
    function compareByProperty (arg: sortArg<T>) {
        let key: keyof T
        let sortOrder = 1
        if (typeof arg === 'string' && arg.startsWith('-')) {
            sortOrder = -1
            // Typescript is not yet smart enough to infer that substring is keyof T
            key = arg.substr(1) as keyof T
        } else {
            // Likewise it is not yet smart enough to infer that arg is not keyof T
            key = arg as keyof T
        }
        return function (a: T, b: T) {
            const result = a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0

            return result * sortOrder
        }
    }

    return function (obj1: T, obj2: T) {
        let i = 0
        let result = 0
        const numberOfProperties = sortBy?.length
        while (result === 0 && i < numberOfProperties) {
            result = compareByProperty(sortBy[i])(obj1, obj2)
            i++
        }

        return result
    }
}

/**
 * Sorts an array of T by the specified properties of T.
 *
 * @param arr - the array to be sorted, all of the same type T
 * @param sortBy - the names of the properties to sort by, in precedence order.
 *                 Prefix any name with `-` to sort it in descending order.
 */
export function sort<T extends object> (arr: T[], ...sortBy: Array<sortArg<T>>) {
    arr.sort(byPropertiesOf<T>(sortBy))
}

Example usage:

interface User {
    name: string
    id: string
    age?: number
}

const users: User[] = [
    {name: 'Harriet Tubman', id: '01', age: 53},
    {name: 'John Brown', id: '02', age: 31},
    {name: 'John Brown', id: '03', age: 59},
    {name: 'James Baldwin', id: '04', age: 42},
    {name: 'Greta Thunberg', id: '05', age: 17}
]

// using Array.sort directly 
users.sort(byPropertiesOf<User>(['name', '-age', 'id']))

// using the convenience function for much more readable code
sort(users, 'name', '-age', 'id')
Related