2

Here is my code:

interface IOrder {
  id: string
  created: string
  name: string
  age: number
}

type OrderKey = keyof IOrder

const columnNames: OrderKey[] = ['id', 'name', 'age', 'created']
const colValGenerators: {[key: string]: (obj: any) => any} = {
  age: (obj: any) => Math.round((Date.now() - Date.parse(obj.created)) / 1000 / 60 / 60 / 24)
}

const orders: IOrder[] = [
  {id: '1', created: 'Thu, 03 Feb 2022 14:59:16 GMT', name: 'John Doe', age: 0},
  {id: '2', created: 'Thu, 04 Feb 2022 14:59:16 GMT', name: 'Jane Doe', age: 0}
]

for (const order of orders) {
  for (const colName in colValGenerators) {
    order[colName] = colValGenerators[colName](order)  //ERROR
  }
}

On the line I have marked above with //ERROR I get the following ts error:

Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ id: string; created: string; name: string; age: number; }'.
  No index signature with a parameter of type 'string' was found on type '{ id: string; created: string; name: string; age: number; }'.ts(7053)

If I am understanding this correctly, the error is indicating that the keys of colValGenerators can be any string, so I may be writing to a parameter that is not part of the IOrder interface.

To fix this I tried changing:

const colValGenerators: {[key: string]: (obj: any) => any}

to:

const colValGenerators: {[key: OrderKey]: (obj: any) => any}

(I changed string to OrderKey.) However that produces the following error:

An index signature parameter type cannot be a literal type or generic type. Consider using a mapped object type instead.ts(1337)

Based on that error I looked into mapped object types. I tried using Property in keyof IOrder but I got an error that says No overload matches this call..

Can anyone give me a pointer here? I don't have much experience with Typescript so I am also open to other ways to solve this problem if what I am trying to do doesn't really fit into the typical way to do things.

jminardi
  • 936
  • 1
  • 11
  • 23
  • 1
    Please provide a [mre] that clearly demonstrates the issue you are facing. Ideally someone could paste the code into a standalone IDE like [The TypeScript Playground (link here!)](https://tsplay.dev/mxYY1W) and immediately get to work solving the problem without first needing to re-create it. So there should be no pseudocode, typos, unrelated errors, or undeclared types or values. – jcalz Apr 07 '22 at 13:16
  • @jcalz Sorry, I was missing the import statement. I have added it now and it seems to load properly in the playground. – jminardi Apr 07 '22 at 13:36
  • If the question does not depend on vue, please consider reducing the example so that there is no such dependency in the code as well. Otherwise, consider tagging the question with vue so that people who already know what's going on with those types might have an idea. As it stands I'm not particularly inclined to try to learn enough about vue to see through that stuff to your actual question. – jcalz Apr 07 '22 at 13:44
  • 1
    @jcalz I have updated the question again to remove the vue dependency – jminardi Apr 07 '22 at 14:03
  • (Note that you have a `.value` typo) Does [this approach](https://tsplay.dev/N7OORN) meet your needs? I gave `colValGenerators` a more appropriate type, and then did the iteration as a generic callback with an assertion (the compiler can't assume that an object's keys will only be the ones it knows about) and an extra check (since optional properties might have an `undefined` value). Let me know if that works for you and I can write up an answer; otherwise, what am I missing? – jcalz Apr 07 '22 at 14:40
  • Yes, that seems to solve the problem for me, thank you – jminardi Apr 07 '22 at 23:47

1 Answers1

1

In TypeScript 4.6 and above it is possible to refactor your code so that the compiler can verify type safety without too much redundancy. The order and colName values inside your loop are correlated to each other in a way that TypeScript hadn't really been able to follow before. There is a (now fixed) issue at microsoft/TypeScript#30581, detailing how you'd historically have to choose between a lot of type assertions (like (order[colName as OrderKey] as any) = colValGenerators[colName](order)) or one line of code per possible value of colName (that is, unroll the loop and write it manually).

But luckily we now have support for improved indexed access inference which lets you annotate values in such a way as to let the compiler follow such correlations:


First we can give colValGenerators a more specific type than {[key: string]: (obj: any) => any}, which (as you noted) forgets about the particular keys, and is further unsafe because each property's callback has both the input and output typed as the any type, is like disabling type checking:

const colValGenerators: { [K in OrderKey]?: (obj: IOrder) => IOrder[K] } =
  { age: (obj) => Math.round((Date.now() - Date.parse(obj.created)) / 1000 / 60 / 60 / 24) }

That might be the mapped type you were looking for. Each property key in OrderKey becomes an optional property in colValGenerators, and the callback takes an IOrder input and returns the appropriate property type for that key. That is, the type of colValGenerators evaluates to:

// const colValGenerators: {
//    id?: ((obj: IOrder) => string) | undefined;
//    created?: ((obj: IOrder) => string) | undefined;
//    name?: ((obj: IOrder) => string) | undefined;
//    age?: ((obj: IOrder) => number) | undefined;
// }

Note that this explicit annotation involving the generic K is part of the solution; the compiler is primed to understand that indexing into colValGenerators with a key of generic type K will give you a callback of a related generic type (obj: IOrder) => IOrder[K], and thus the correlation between the index and the output will be maintained.


Then, when you iterate through the properties of colValGenerators, you need to use a type assertion to tell the compiler that the only keys present will be ones in OrderKey, like

Object.keys(colValGenerators) as OrderKey[]

Because object types in TypeScript are not "sealed" or "exact" (as mentioned in ms/TS#12936), it's always possible for for (const k in obj) or Object.keys(obj) to produce keys that the compiler doesn't know about. See this Stack Overflow question/answer about Object.keys() for more information. Since you yourself made colValGenerators, you can use an assertion and be confident that it won't break.

Of course, instead of Object.keys(colValGenerators), you could use columnNames, since that's already been annotated as OrderKey[]. Yes, it may have more keys than colValGenerators, but as long as we check colValGenerators[colName] before calling it like a function, it won't matter much. Let's go with that for the rest of the answer.


Finally, when iterating over the column names, we will use the forEach() method and give it a generic callback where the type of colName is a type parameter K extends OrderKey. This will allow the compiler to see the correlation between order and colName and verify that order[colName] = colValGenerators[colName](order) is safe and correct:

for (const order of orders) {
  columnNames.forEach(
    <K extends OrderKey>(colName: K) => {
      const generator = colValGenerators[colName];
      if (generator) { order[colName] = generator(order); } // okay
    }
  );
}

That works as desired, hooray!

Note that we have to do a runtime check to see that colValGenerators[colName] is really present. Of course we need this check when iterating over columNames, but the compiler would want you to do that even for Object.keys(colValGenerators), because optional properties may be undefined even if they're present. If you do iterate over colValGenerators properties and want to skip that check, you could write colValGenerators[colName]!(order) with the non-null assertion operator, but then you'd better be sure you know what you're doing. Personally I think the extra runtime check is fine.


And there you go, it works! Note that I switched from a for loop to a forEach() array method. It's actually hard to write this in a way the compiler sees as type-safe if you do a for loop. The type checking really only works if colName is of generic K extends OrderKey type. If you do it in a for loop, where colName is just of type OrderKey, you'd need to pass it to some generic function, It ends up looking like

for (const order of orders) {
  for (const colName of columnNames) {
    (<K extends OrderKey>(colName: K) => {
      const generator = colValGenerators[colName];
      if (generator) { order[colName] = generator(order); }
    })(colName);
  }
}

which is almost the same as using a forEach() callback except weirder. So we might as well just use forEach().

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • 1
    Thank you for the detailed answer, it is very informative. Typescript is the first strongly typed language that I've done serious work in and sometimes it feels like I spend half my time fighting the compiler. You've given me a lot to read which is excellent but it makes me feel like I have a long way to go before I am proficient in ts. – jminardi Apr 08 '22 at 11:34