yankee
yankee

Reputation: 40810

How to type-annotate a TypeScript function that maps object properties?

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?

Upvotes: 0

Views: 2530

Answers (2)

Karol Majewski
Karol Majewski

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 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')
)

Upvotes: 1

brunnerh
brunnerh

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

Related Questions