1

I'm trying to implement a multi-dimensional array sort function as outlined here: https://stackoverflow.com/a/55620991/2175753

It's not overly complicated, however the type setup for the function gives me headaches atm.

This is what I have so far:

export function orderBy<T, U extends keyof T>(array: Array<T>, keys: Array<U>, sort: (key: U, a: T[U], b: T[U]) => number) {
    const records = keys.map(key => {
        const record = {
            key,
            uniques: Array.from(new Set(array.map(item => item[key])))
        }
    
        record.uniques = record.uniques.sort((a, b) => sort(key, a, b))
    
        return record
    })
    
    const weightOfObject = (obj: T) => {
        let weight = ''
        records.map(record => {
            let zeropad = `${record.uniques.length}`.length
            weight += record.uniques.indexOf(obj[record.key]).toString().padStart(zeropad, '0')
        })
    
        return weight
    }

    return array.sort((a, b) => weightOfObject(a).localeCompare(weightOfObject(b)))
}

On the callsite, it looks something like this:

enter image description here

The issue is, I'm not able to narrow the types of a and b further. I expected this to work:

enter image description here

I've tried to adjust the function signature to explicitly denote the callback as generic like so:

export function orderBy<T, U extends keyof T>(array: Array<T>, keys: Array<U>, sort: <V extends U>(key: V, a: T[V], b: T[V]) => number) { ... }

But to no avail.

It this something TypeScript does not support or am I doing something wrong?

CodingMeSwiftly
  • 3,231
  • 21
  • 33

1 Answers1

3

UPD: this answer is partly outdated, since late TS 4.x versions introduced tracking relations between destructured variables. However, relations between function arguments will probably not ever be preserved (except maybe for overloaded function, but they are not useful in OP's case), so the suggested solution is still valid.


SOMEWHAT OUTDATED PART

The problem is typescript only keeps track of connections between values' types as long as they are in the same variable, like two properties of one object or two elements of one tuple. As soon as you separate these values into different variables, for example by destructuring them or just passing as 2 distinct arguments in a function, like in your case, all connections are lost and there is no way to restore them (at least for now, maybe one day it will be possible). Consider a situation that may come up very often

type Foo = 
  | { type: 'a', value: number }
  | { type: 'b', value: string }

declare const obj: Foo
// Here there is connection between obj.type and obj.value
switch(obj.type) {
  case 'a':
    // Here obj.value is of type number
    break
  case 'b':
    // Here obj.value is of type string
    break
}

But if you do this

const {type, value} = obj
// At this point type and value are distinct variables 
// and any connection they had is lost
switch(type) {
  case 'a':
    // Here value is number | string
    break
  case 'b':
    // And here as well
    break
}

And you cannot do anything about it, you have to keep the values in the same object for typescript to retain any useful information on how their types depend on each other.


VALID SOLUTION

So this is exactly what you could do in your scenario: put these values in an object and don't destructure it. Here is one way. The first attempt could look like this:

declare function orderBy<T, U extends keyof T>(
  array: T[], 
  keys: U[], 
  sort: (data: {key: U, a: T[U], b: T[U]}) => number
): T[]

But it will actually not work, because T[U] is number | Date in the particular example you provided, so the dependency between types is lost right away. To keep it you need to kind of "map" this union type U so that each member of this union is mapped to an object with fitting types. This can be accomplished with a helper type like this:

type MapKeysToSortData<T, U extends keyof T> = {
  [TKey in U]: {
    key: TKey,
    a: T[TKey],
    b: T[TKey],
  }
}[U]

So you are creating an object where keys are members of U, you assign an object to every key like this which is strictly typed, then you get a union of these objects with this [U] at the end. So the function will look like this:

declare function orderBy<T, U extends keyof T>(
  array: T[], 
  keys: U[], 
  sort: (data: MapKeysToSortData<T, U>) => number
): T[]

And that's pretty much it. You may have some problems when adding implementation to this declaration, I'm not going to type it here, anyway you can throw in some type assertion and it will do it. Although I'm pretty sure you can type it without type assertions in 2021.

Now use the function without destructuring the argument in the callback (at least up to the moment when typescript already inferred everything you needed)

orderBy([testObject], ['age', 'eta'], (data) => {
  if(data.key === 'eta') {
    // Here TS already knows data.a and data.b are of type Date, so you can destructure them
    const {a, b} = data
    console.log(a.toUTCString(), b.toUTCString())
  } else {
    // Here data.a and data.b are of type number
  }
  return 1
})

Check out a sandbox

Also I shall note that you are not sorting multidimensional arrays, just one-dimensional arrays of objects. And please don't insert screenshots of your code, copy the actual code as text, and add comments describing these hover thingies of your IDE, it's a bit annoying to copy your test data from an image.

Alex Chashin
  • 3,129
  • 1
  • 13
  • 35
  • Wow! Very detailed answer, thank you. Your solution works, although I have to admit I don't fully understand it, yet. I guess I need to read up on the type system a lot. :) One thing: When I have strict mode enabled, the compilers warns me about data being possibly undefined. I can work around it by optional chaining (data?.key). Do you have an idea why the warning shows up? – CodingMeSwiftly Aug 23 '21 at 21:07
  • The sandbox you shared has the same tsconfig that I use locally. The sandbox doesn't warn about data possibly being undefined, VSCode does. Very odd. – CodingMeSwiftly Aug 23 '21 at 21:15
  • Well, no, to be honest, I don't see why there could such a warning, because there are no undefined fields :p Maybe vscode is a bit confused and you need to restart it, maybe there are some differences between your code and sandbox, I can't say – Alex Chashin Aug 24 '21 at 11:46