10

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.

Inigo
  • 12,186
  • 5
  • 41
  • 70
Andrew O.
  • 171
  • 1
  • 9
  • Typescript *is* Javascript, so the "implementation" is exactly the same. What your question should be: *How do I properly add type info to this JS sort function to make it typesafe?* – Inigo Jul 07 '21 at 01:23
  • Also, please fix your playground link. – Inigo Jul 07 '21 at 01:25
  • Andrew, my last title edit made it useful for future SO users. *"How to extend a `keyof` type so that it includes modified versions of the keys, e.g. prefixed with '-'?"* is the key part of your problem, the thing you couldn't solve, and is what makes this Q and A valuable. Otherwise it is a generic question that is a dupe of others. Please change it back. Especially since I spent time on your problem and solved it (are you going to accept it or is it not good enough?). Or go back to your title ("Dynamic multiple sort in asc and desc order Typescript") and I remove my answer and upvote? – Inigo Jul 09 '21 at 01:40
  • @Inigo I agree with you. I like your new title. As I see it is already changed. Should I do something else? – Andrew O. Jul 09 '21 at 09:55

1 Answers1

18

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 here is 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')
Inigo
  • 12,186
  • 5
  • 41
  • 70
  • HOw can i convert this to sort an array rather than an object? – Mr Pablo Jul 21 '21 at 09:40
  • Sorry, I mean, I have an array I want to sort, how do I pass that to this function to be sorted? I can't see where the data to be sorted is passed... eg I have an array of objects that I want to sort by date then name, so I'd assume i need to pass the array, then the sorting args? – Mr Pablo Jul 21 '21 at 10:35
  • If I do `const sort = dynamicSortMultiple(SORT_ARRAY);` then `sort(data)` i get the error "Expected 2 arguments, but got 1" – Mr Pablo Jul 21 '21 at 10:43
  • @MrPablo, you're right, the naming is confusing and it just confused me. `dynamicSortMultiple` produces as *Compare function* that you can pass into `Array.sort`, so you should do `data.sort(sort)`... which is horrible naming. Give me a few minutes and I'll improve the API and add example usage. – Inigo Jul 21 '21 at 11:44
  • did you ever figure out how to add support for sorting by nested value in an object? – Mr Pablo Dec 13 '22 at 09:46
  • @MrPablo Do you mean "sort an array of objects by property values of objects nested within those objects"? If so, then no, because that was never the question. Also, the problem above is *not* about sorting implementation, but how to do that in a type-safe way using Typescripts static type checking. – Inigo Dec 14 '22 at 01:37