4

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

ghiscoding
  • 12,308
  • 6
  • 69
  • 112
  • Shouldn't `customEditableInutFormatter` be something like `const customEditableInputFormatter: Formatter = (row: number, cell: number, value: any, columnDef: Column, item: ReportItem) => {return '';};`? – julianobrasil Jun 10 '20 at 18:55
  • These Formatters are typically outside in separate files and must be generic so that I can reuse them for other Types not just `ReportItem`. I could do it like you said but that defeats the purpose of what I'm trying to achieve. I really wish to have the column definition push the generic type down to the formatter – ghiscoding Jun 10 '20 at 19:47
  • Forget what I said above. I've misread your question. – julianobrasil Jun 10 '20 at 20:25
  • New theory :D. As you're declaring a constant, you're setting `Formatter` type to `any`. If you declare it as a generic function you'll control the returned type because it'll be dynamically created at the time you call the function. Like this: `const customEditableInputFormatter: () => Formatter = () => (..., item: T) => {return '';};`. Down in the array you could set `formatter: customEditableInputFormatter()`. The editor would show you something closer to what you want. – julianobrasil Jun 10 '20 at 20:38
  • I don't have any errors except that it doesn't work, I don't get the typing in the intellisense. Also I wouldn't be able to use it this way because in this grid library you can only pass the function reference (the fn code) without executing it, if I execute it that would basically return the same value to every row in the grid because the fn got executed already. In other words, I can only pass a reference to the fn code, it will run it once it reaches it but not before and not just once, so I have to keep it as `formatter: customEditableInputFormatter` without calling it `()` – ghiscoding Jun 10 '20 at 23:30
  • I edited the question with more steps I've tried, still unsuccessful. I have a feeling that I need some kind of TS utility/helper to put the type, but I'm not too familiar with those. – ghiscoding Jun 10 '20 at 23:54
  • Well, I'm not sure if I'm following you. In the last example, I'm turning `customEditableInputFormatter` into a factory function. It won't be executed just once. You'll call it just once, but it will return another function with the right type. I'm gonna write an answer just to show you what I've got here (I know it isn't exactly what you're asking for, but it's a step in that direction). – julianobrasil Jun 11 '20 at 02:44
  • @julianobrasil is correct: declaring `customEditableInputFormatter` in your example with `T = any` will never be anything other than `any` when you check its type. Hover text in the screenshots are expected for the code provided. If you're trying to simplify your example, then it's not capturing what you're intending. Please add simple examples of formatters in multiple files and clarify what you mean by "I really wish to have the column definition push the generic type down to the formatter". You said you're learning about generics, and this looks like the key to your misunderstanding. – rob3c Jun 11 '20 at 07:48

2 Answers2

3

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!

rob3c
  • 1,936
  • 1
  • 21
  • 20
  • 1
    Thanks there is a lot of instructive details in your answer. I add the `` anywhere I think that user can use with/without padding the Type to the Generic, so I think it's ok in using them in many other interfaces but I understand it's different in my question and why it shouldn't be there, you made a good point. You were right about the typo. Finally I was thinking too much about auto-magic to happen, your answer helps understanding that there's a limit to that, thanks ;) – ghiscoding Jun 17 '20 at 14:12
1

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.

julianobrasil
  • 8,954
  • 2
  • 33
  • 55
  • 1
    ok I see what you mean with the factory, after changing the code I do get the returned type like you have in screenshot on the `formatter: customEditableInputFormatter()`, however inside the Formatter itself I still see `(parameter) item: T` which is not very helpful in my use case because that is actually what I'm trying to fix, to get the type in there. – ghiscoding Jun 11 '20 at 12:16
  • Actually, the upper part of the dialog was there just to show I was referring to the definition of `customEditableInputFormatter` (by pressing CMD key while the mouse pointer was over the function call). Without pressing anything, by just hovering the pointer over the function call it shows only the lower part of the dialog, without the function definition which was almost what you were trying to fix in your original question (at least it's what is in one of the images). The second part, not inferring the type of the generics is still missing (`item.title = 'some string'` causes an IDE error). – julianobrasil Jun 11 '20 at 12:48