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