Problem with using a defined TypeScript Generic Type in an external function using the Type T

Viewed 1095

I'm trying to add more Generic Types to my Open Source library Angular-Slickgrid which is a Data Grid library.

I defined an interface for the Column Definitions which can have multiple Type Column, I'm adding a new Generic Type to this Column interface and I'd like to use this new Type in the formatter option which itself is of Type Formatter as shown below. The new Generic Type is basically to tell the Formatter, the Type of item object that it could be.

export interface Column<T = any> {
  field: string;

  // oops that was a typo
  // formatter?: Formatter: T; 

  // correct code is actually this
  formatter?: Formatter<T>;
}

The Formatter is defined as follow

// add Generic Type for the item object
export declare type Formatter<T = any> = (row: number, cell: number, value: any, columnDef: Column, item: T) => string;

Now I'm trying to use the new Generics by creating a ReportItem interface that is passed to the columnDefinitions so that I could (wish) to use the ReportItem type inside my Formatter

interface ReportItem {
  title: string;
  duration: number;
  cost: number;
  percentComplete: number;
  start: Date;
  finish: Date;
  effortDriven: boolean;
}

const customEditableInputFormatter: Formatter = <T = any>(row: number, cell: number, value: any, columnDef: Column, item: T) => {
  // I want the `item` to be of Type ReportItem but it shows as Type any
  // item.title 
};

export class MySample {
  columnDefinitions: Column<ReportItem>[];

  initializeGrid() {
    this.columnDefinitions = [
      {
        id: 'title', name: 'Title', field: 'title', sortable: true, type: FieldType.string,
        formatter: customEditableInputFormatter,
      }
    ];
  }
}

If I hover over the formatter property, I see the correct Formatter<ReportItem> with intellisense (in VSCode). formatter property intellisense

But if I hover over the Custom Formatter itself, I get Formatter<any>

custom formatter intellisense

So my question is how can I code it so that it really takes the correct Type ReportItem in my external Formatter, I want my external Formatter to be of Type Formatter<ReportItem> instead of Formatter<any>. I'm starting to learn Generics and I find it very powerfull but still have some ways to learn them.

Thanks for any help provided.

EDIT 1

I also tried to replace <T = any> by <T> on the custom formatter (const customEditableInputFormatter: Formatter = <T>...) and add a customEditableInputFormatter as Formatter<ReportItem> when using it, but it still shows as T being any

formatter property 2

custom formatter type 2

2 Answers

Overview

The problem appears to be mostly a matter of using the proper generics syntax, which looks like it may reflect a slight misunderstanding while you're learning the new concepts.

I've rewritten your code into a working form below. Pay close attention to where generic parameters appear relative to colons and assignment operators, as it can make the errors quite subtle. The TypeScript compiler doesn't help, because the provided declarations are still valid syntax (except for squiggles on item.title in nonworking versions), it's just not the correct syntax for what you're trying to achieve. TypeScript doesn't understand what you're trying to do, only what you did :-)


Column<T>

First, let's fix that Column<T> definition so it properly types the formatter property. It may be just a typo, but I wanted to mention it. (I also added the missing properties that are used in your MySample.initializeGrid() method):

export interface Column<T = any> {
  field: string;
  id: number | string;
  name?: string;
  sortable?: boolean;
  type?: FieldType;
  formatter?: Formatter<T>; // originally formatter?: Formatter: T;
}

Formatter<T>

Now, here's your generic Formatter<T> definition, which is ok:

export declare type Formatter<T = any> = (
  row: number, cell: number, value: any, columnDef: Column, item: T
) => string;

However, based on your Column<T> definition that parametrizes the formatter property with T, it looks like you might want to specify the columnDef arg with the same generic param T that matches item, like this:

export declare type Formatter<T = any> = (
  row: number, cell: number, value: any, columnDef: Column<T>, item: T
) => string;

But that's an extra detail you'll have to decide. Now onto the main problem...


customEditableInputFormatter solution (TLDR)

First, before diving into a bunch of TypeScript language syntax details, let me just give the most concise TLDR one-liner working version. It leverages TypeScript's type inference, so all arguments and return value are strongly-typed based on declaring it as Formatter<ReportItem>:

const customEditableInputFormatter: Formatter<ReportItem> = (r, c, v, d, i) => i.title;

You can write out the full argument names, specify all of their types, specify the return type, and add curly braces around the function body, but this is all that's strictly necessary for strong typing, intellisense, etc. And here's a slightly longer form that doesn't rely on an IDE to tell the reader what the types are:

const customEditableInputFormatter: Formatter<ReportItem> = (
  row: number, cell: number, value: any, columnDef: Column<ReportItem>, item: ReportItem
) => {
  return item.title; // item is type `ReportItem`
}

If you don't care about ensuring the signature matches a Formatter<T> to help avoid errors when creating the function, you can even shorten it to this without declaring it as Formatter<ReportItem>:

const customEditableInputFormatter = (
  row: number, cell: number, value: any, columnDef: Column<ReportItem>, item: ReportItem
) => {
  return item.title; // item is type `ReportItem`
}

TypeScript would still allow the assignment later to Formatter<T>, but it's prone to error, and I don't recommend it.


customEditableInputFormatter error analysis

So with that out of the way, let's look at your code in more detail...

This is your original version of the customEditableInputFormatter declaration:

const customEditableInputFormatter: Formatter = <T = any>(
  row: number, cell: number, value: any, columnDef: Column, item: T
) => {
  // I want the `item` to be of Type ReportItem but it shows as Type any
  // item.title // <-- oops, item is type `T` here, i.e. `any`
};

There are a few things to notice in this version:

  1. On the left side of the assignment operator (first equals sign), you'll see that you've declared its type as Formatter without actually specifying its type argument T. Therefore, it defaults to any, as specified in the type declaration (<T = any>), and it'll never be anything else.
  2. On the right side of the assigment operator, you have <T = any> right before opening parenthesis of the function. This isn't parametrizing the Formatter type on the left side - that's already been specified in #1. This is declaring a generic parameter for the new anonymous generic function you've created on the right side! Those are two different Ts, which I think is adding confusion here! The declaration is compatable with assignment to a Formatter<any>, so TypeScript doesn't complain.
  3. Surprisingly, changing #2 to <T = ReportItem> still doesn't fix the problem, even though hovering over item in the function body says it's type ReportItem! How can that be? This is a bit tricky, but it's because the function body has to support ALL possible Ts, not just when T is the default ReportItem. TypeScript can't know that any random T will have a title property, so it won't let you use it on item even though we know it has one in this case. It's counterintuitive at first, but it'll make sense once you think about it a bit. In any case, the better way to declare it is in the working version above where this isn't an issue.

a suggestion

Finally, I'd recommend removing all of the = any default type parameters, if you can. I think it played a big part in obscuring what was wrong by preventing TypeScript from helping more directly. You mentioned adding types to an existing codebase, so maybe that makes it more challenging. However, removing them allows the language service to flag issues that default types obscure. They can be quite handy when used judiciously, but I'd skip them unless explicitly needed.


Ok, that's quite a lot, and there are still many other possible things to say about various generics issues like this. However, I'm going to end here since the question is answered. Hopefully, you'll find this helpful and continue your study and use of generics in TypeScript!

Let's turn customEditableInputFormatter into a higher-order function:

const customEditableInputFormatter: <T>() => Formatter<T> = () => 
  <T>(row: number, cell: number, value: any, 
    columnDef: Column, item: T) => '';

The above higher-order function returns another function that, in turn, returns a string (I know it's pretty useless, but it can show what I've got on VSCode by doing that):

enter image description here

In the above image, you can see the type embedded in the returning type of the higher-order function, instead of just Formatter<any>.

This approach obviously overcomplicates the code in favor of the IDE intellisense, so IMO, it's not a good candidate for using in your code. But maybe it can put some light on the problem to help finding a good solution though.

Related