Search code examples
typescriptgenericsecmascript-6typescript2.0ecmascript-5

Generics: TS2345 Argument of type 'T[keyof T]' is not assignable to parameter of type 'Date' where <T extends {}>


I often have to sort objects by properties, so I wrote the following simple helper:

static sort = <T extends {}>(list: Array<T>, field: keyof T, descending: boolean = true) =>
  list.sort((a, b) => {
    if (a[field] > b[field]) {
      return descending ? -1 : 1;
    }
    if (a[field] < b[field]) {
      return descending ? -1 : 1;
    }
    return 0;
  });

I often have to sort objects by a date-like property on the object, but the dates I receive are stringified in the format "01-Apr-2018", so I have to convert them into a Date first.

So, I expanded the above with:

static byDate = <T extends {}>(list: Array<T>, field: keyof T, descending: boolean = true) =>
  list.sort((a, b) => {
    if (new Date(a[field]).getTime() > new Date(b[field]).getTime()) {
      return descending ? -1 : 1;
    }
    if (a[field] < b[field]) {
      return descending ? 1 : -1;
    }
    return 0;
  });

which results in

TS2345: Argument of type 'T[keyof T]' is not assignable to parameter of type 'Date'

on a[field] and b[field].

Tracing WebStorm's / TypeScript Service's implementation of Date that was chosen, I noticed that in new Date("hello"), it chooses the following interface from lib.es5.d.ts:

interface DateConstructor {
    new(): Date;
    new(value: number): Date;
    new(value: string): Date; // THIS ONE (which I expect)
    new(year: number, month: number, date?: number, hours?: number, minutes?: number, seconds?: number, ms?: number): Date;
    (): string;
    readonly prototype: Date;
    parse(s: string): number;
    UTC(year: number, month: number, date?: number, hours?: number, minutes?: number, seconds?: number, ms?: number): number;
    now(): number;
}

But in my sort comparator, it chooses the interface from lib.es2015.core.d.ts, which only has:

interface DateConstructor {
    new (value: Date): Date;
}

Attempting to assert with new Date(a[field] as string) or as Date has no effect.

I'm using TypeScript 2.7.2 (required by my current Angular 6.0.9 project and can't change).

So:

  1. Why does tsc choose one over the other in these contexts?

  2. How can I assert which one I want it to use, or at the very least, get my code to compile while maintaining the field: keyof T parameter?


Solution

  • I can't test on 2.7.2 right now, but the main issue is that TypeScript doesn't believe that a[field] is necessarily a string. And that makes sense because a is of generic type T, and field is of type keyof T, so all it knows is that a[field] is T[keyof T]. That might be string, or it might be something that you can't call new Date() on.

    If you know for sure that you will only call byDate on something where a[field] is a string, then you can change the generics to be like this:

    static byDate = <K extends keyof any, T extends Record<K, string>>(
      list: Array<T>, 
      field: K, 
      descending: boolean = true
    ) => { ... }
    

    That should make the error go away... it now knows that a[field] is type T[K], where T extends Record<K, string>, also known as {[P in K]: string}, or "an object whose keys are in K and whose values are string". So T[K] extends string and it's happy.

    The above should work on TypeScript 2.7.2 but let me know if you run into a problem.

    Hope that helps. Good luck!