0

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:

  1. The line 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?
  2. 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?

2 Answers 2

1

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 your properties (denoted by T[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')
)
Sign up to request clarification or add additional context in comments.

2 Comments

Cool, I needed to edit the function only slightly to work for me. Now I try to create some default wrapper like this: const convertDateProperties = <T, K extends keyof T>(obj: T, ...propertyNames: K[]) => mapProperties(obj, (val: string) => new Date(val), propertyNames);. But of course this does not work, because I did not specify that T[K] must be a string. Do you happen to know a solution for this as well?
You need to make sure the values (T[K]) are of type string by adding a constraint on T. Do this: const convertDateProperties = <T extends { [index in K]: string }, K extends keyof T>(obj: T, ...propertyNames: K[]) => mapProperties(obj, (val: string) => new Date(val), ...propertyNames);
0

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] }

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.