Reputation: 40810
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 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
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:
WRAPPER_KEYS extends (keyof WRAPPER_TYPE & PropertyNamesByType<WRAPPER_TYPE, OLD_TYPE>)
. I think it should work with just WRAPPER_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?(<any>result[key]) = mapper(<any>obj[key]);
I need those two <any>
-casts. Is there any way to get rid of those?Upvotes: 0
Views: 2530
Reputation: 25790
Helper types:
type Overwrite<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
type Morphism<T = any, U = any> = (argument: T) => U;
Example implementation:
const transform = <T, U extends Morphism<T[K]>, K extends keyof T>(source: T, mappingFn: U, ...properties: K[]) =>
(Object.entries(source))
.reduce(
(accumulator, [key, value]) => {
const newValue =
properties.includes(key as K)
? mappingFn(value)
: value
return ({ ...accumulator, [key]: newValue })
},
{} as Overwrite<T, Record<K, ReturnType<U>>>
);
Remarks:
U extends Morphism<T[K]>
makes sure the transformer accepts only the values of yourproperties
(denoted byT[K]
).ReturnType
requires TypeScript 2.8 or higher
Usage:
const source = {
dateStringA: "2019-01-02T03:04:05",
dateStringB: "2019-01-03T04:05:06",
nonDateString: "foobar",
someNumber: 123
}
const toDate = (date: string) => new Date(date);
console.log(
transform(source, toDate, 'dateStringA', 'dateStringB')
)
Upvotes: 1
Reputation: 184607
You can map on whether the property appears in the key list, then either use converted type or original type:
// (just the type signature)
declare function mapProperties<Json, SourceType, TargetType, P extends keyof Json>(
obj: Json,
converter: (value: SourceType) => TargetType,
...keys: P[]): { [K in keyof Json]: K extends P ? TargetType : Json[K] }
Upvotes: 0