My preferred method in this scenario is to introduce an additional generic type parameter K
that corresponds to the key of the T
you are providing. Then, I apply a constraint on T
to ensure it can be assigned to a type with a property of type string | number
at the key K
. This constraint involves using the Record
utility type:
function sortData<T extends Record<K, string | number>, K extends keyof T>(
data: Array<T>, column: K, direction: string): Array<T> {
if (direction === '' || column === '') {
return data;
} else {
return [...data].sort((a, b) => {
const res = compare(a[column], b[column]);
return direction === 'asc' ? res : -res;
});
}
}
The code now compiles without errors and maintains the desired functionality when called:
interface MyType {
foo: number, //sortable
bar: { x: number, y: number } //not sortable
}
var x: MyType[] = [
{ foo: 5, bar: { x: 1, y: 2 } },
{ foo: 3, bar: { x: 3, y: 2 } }
]
console.log(sortData(x, "foo", 'asc')) // valid
console.log(sortData(x, 'bar', 'asc')) // error!
// ----------------> ~
// Argument of type 'MyType[]' is not assignable to parameter
// of type 'Record<"bar", string | number>[]'.
Despite the slightly strange error message focusing on the array rather than the column, we can improve clarity by modifying the constraint on column
. By creating a utility type that utilizes a conditional type for filtering, we can address this issue. Here's how it can be implemented:
type StrNumKeys<T> = keyof {
[K in keyof T as T[K] extends string | number ? K : never]: any
}
Using key remapping, inappropriate keys can be filtered out. While there are alternative ways to define StrNumKeys
, let's proceed with testing it against your MyType
type:
type Test = StrNumKeys<MyType>;
// type Test = "foo"
Everything appears to be correct. Therefore, it becomes unnecessary for column
to be generic when employing this utility type:
function sortData<T extends Record<StrNumKeys<T>, string | number>>(
data: Array<T>, column: StrNumKeys<T>, direction: string): Array<T> {
if (direction === '' || column === '') {
return data;
} else {
return [...data].sort((a, b) => {
const res = compare(a[column], b[column]);
return direction === 'asc' ? res : -res;
});
}
}
This version also successfully compiles since a[column]
effectively accesses elements from
Record<StrNumKeys<T>, string | number>
with keys of type
StrNumKeys<T>
. Consequently, the compiler recognizes these values as compatible with
string | number
. As a result, when calling the function, better error messages are generated for invalid cases:
console.log(sortData(x, "foo", 'asc')); // valid
console.log(sortData(x, 'bar', 'asc')); // error
// -------------------> ~~~~~
// Argument of type '"bar"' is not assignable to parameter of type '"foo"'.
The choice between these two approaches is yours: the two-generic variant relies on more common TypeScript features, while the single-generic variant employs conditional types in a more intricate manner. If the latter option leads to unexpected behavior in specific scenarios, reverting to the former might be advisable.
Link to Playground for Code