Background
Let's say I want to load an object from this JSON:
{
"dateStringA": "2019-01-02T03:04:05",
"dateStringB": "2019-01-03T04:05:06",
"nonDateString": "foobar",
"someNumber": 123
}
So the two properties dateStringA and dateStringB should actually be of type Date, but since JSON does not know a type Date it is a string and needs to be converted. So an option might be to write a simple mapping function that converts the the properties like this in plain old JavaScript:
function mapProperties(obj, mapper, properties) {
properties.forEach(function(property) {
obj[property] = mapper(obj[property]);
});
return obj;
}
var usefulObject = mapProperties(
jsonObject,
function(val) {return new Date(val);},
'dateStringA',
'dateStringB'
);
The question
The above works fine, but now I want to do the same in TypeScript and of course I would like to add as many type checks as possible. So in best case I would like to get the following result:
// setup
const value = {dateStringA: '2019-01-02T03:04:05', dateStringB: '2019-01-03T04:05:06', nonDateString: '', someNumber: 123};
const result = mapProperties(value, (val: string): Date => new Date(val), 'dateStringA', 'dateStringB');
// --- TEST ---
// dateStringA & dateStringB should be dates now:
result.dateStringA.substr; // should throw compile error - substr does not exist on type Date
result.dateStringB.substr; // should throw compile error - substr does not exist on type Date
result.dateStringA.getDate; // should be OK
result.dateStringB.getDate; // should be OK
// nonDateString is still a string
result.nonDateString.substr; // should be OK
result.nonDateString.getDate; // should throw compile error - getDate does not exist on type string
// someNumber is still a number
result.someNumber.toFixed; // should be OK
// call not possible on properties that do not exist:
mapProperties(value, 'doesNotExist'); // should throw compile error
// call not possible on properties not of type string:
mapProperties(value, 'someNumber'); // should throw compile error
What I have tried so far:
This is the best I got by myself:
type PropertyNamesByType<O, T> = { [K in keyof O]: O[K] extends T ? K : never }[keyof O];
type OverwriteType<T, K extends keyof T, N> = Pick<T, Exclude<keyof T, K>> & Record<K, N>;
function mapProperties<
WRAPPER_TYPE,
WRAPPER_KEYS extends (keyof WRAPPER_TYPE & PropertyNamesByType<WRAPPER_TYPE, OLD_TYPE>),
OLD_TYPE,
NEW_TYPE
>(obj: WRAPPER_TYPE,
mapper: (value: OLD_TYPE) => NEW_TYPE,
...properties: WRAPPER_KEYS[]
): OverwriteType<WRAPPER_TYPE, WRAPPER_KEYS, NEW_TYPE> {
const result: OverwriteType<WRAPPER_TYPE, WRAPPER_KEYS, NEW_TYPE> = <any>obj;
properties.forEach(key => {
(<any>result[key]) = mapper(<any>obj[key]);
});
return result;
}
This actually seems to work, but there are two oddities:
- The line
WRAPPER_KEYS extends (keyof WRAPPER_TYPE & PropertyNamesByType<WRAPPER_TYPE, OLD_TYPE>). I think it should work with justWRAPPER_KEYS extends PropertyNamesByType<WRAPPER_TYPE, OLD_TYPE>, without the& keyof WRAPPER_TYPE, because the later should actually not add any additional information (I discovered this quite by accident). However if I omit this, TypeScript will behave as if ALL string properties were converted. What magic is happening there? - In the line
(<any>result[key]) = mapper(<any>obj[key]);I need those two<any>-casts. Is there any way to get rid of those?