Skip to content

Mapping engine which converts from one data structure to another while working with Typescripts generics to allow for strong typing

License

Notifications You must be signed in to change notification settings

inocan-group/typed-mapper

Repository files navigation

Typed Mapper

transform from one data structure to another with the benefit of Typescript's typing

Install

npm install typed-mapper
# or
yarn install typed-mapper

Usage

Basics

If you have a type A and want to map it to type B; you would do something like:

export interface A {
  firstName: string;
  lastName: string;
  nick: string;
}

export interface B {
  fullName: string;
  nickname: string;
  country: string;
}

const data = {
  firstName: "Bob",
  lastName: "Marley",
  nick: "papa wailer"
};

const converted = TypedMapper.map<A, B>({
  fullName: i => `${i.firstName} ${i.lastName}`,
  nickname: "nick",
  country: () => "usa"
}).convertObject(data);

What we see here are the use of two methods:

  1. map() - allows us to state the output structure and how it maps data from the input
  2. convertObject() - applies the mapping to the supplied data

We also see mapping allowing us two distinct types of transformations:

  • Name Mapping: by stating an output properties value as a string you are telling it
  • Function Mapping: a value of a function in a mapper allows us to use programatic operation and in the example of fullName above we're taking advantage of the function being passed the input object to work off of. Of course if we just want to set a static value or don't need the input then we can just ignore the parameter passed in (see country as example of this).

And of course with a library called "TypedMapper" you will not be surprised to hear that this library is fully typed and will ensure that both your input and output data structures are typed correctly.

List Processing

The example above is very useful but often it would be even more useful if you could apply the same transformation rules to an array of data. This is easily done with nearly the same syntax:

const data: A[] = [{...},{...}];
const converted = TypedMapper.map<A, B>({
  fullName: i => `${i.firstName} ${i.lastName}`,
  nickname: "nick",
  country: () => "usa"
}).convertArray(data);

As you can see, so long as you pass in an array of data, TypedMapper will pass back an array of mapped data. We do change the call of convertObject to convertArray to ensure the type system is completely happy but otherwise precisely the same.

Once you start dealing with lists, however, there are cases where your "function mappers" will want more context than just the current record being converted but rather have access to the full array of inputs to build it's logic off. Here's a silly example:

const data: A[] = [{...},{...}];
const converted = TypedMapper.map<A, B>({
  fullName: i => `${i.firstName} ${i.lastName}`,
  firstNameBuddies: (i, list) => list.filter(l = l.firstName === i.firstName).length,
  nickname: "nick",
  country: () => "usa"
}).convertArray(data);

Passthroughs

Another mapping pattern that is supported is the idea of "passthroughs". This term refers to the desire to pass input values directly through to output values. Here are some examples of how you might use this:

// pass ALL properties from A to B
const mapper = TypedMapper.passthrough<A, B>(true);
// pass specific properties from A to B
const mapper = TypedMapper.passthrough<A, B>(["foo", "bar"]);
// pass ALL properties from A to B, except those stated
const mapper = TypedMapper.exclude<A, B>(["foo", "bar"]);

This would, for each user, give a numeric count of how many other users share the same first name. Not super useful but hopefully you can think of better examples for your data.

Note: that this too will check your typing and reject invalid conversions at design time but there are some limits to the completeness of the check which I believe are true limitations of TypeScript atm. One suggestion, if you have a passthrough the changing the output type O to Partial is likely going to be what you want.

Global Naming Convention Changes

Sometimes all that's needed to move from interface A to B is to change the property names from one standard naming convention to another (e.g., camelCase to kabab-case or snake_case, etc.). This is very simple so let's take a look at a quick example:

interface IInput {
  full_name: string;
  user_age: number;
}
interface IOutput {
  fullName: string;
  userAge: number;
}
const inputData: IInput = { full_name: "Bob", user_age: 25 };
const outputData = TypedMapper<IInput, IOutput>
  .input(inputData)
  .camelize()
  .convert();

In this case we've taken the input structure's properties and converted them all to camelCase. The other naming conventions supported in this global manner are:

  • dasherize (e.g., "foo-bar")
  • pascalize (e.g., "FooBar")
  • snakize (e.g., "foo_bar") yuck, that's a bit ugly on the naming side ... oh well

Note: in Javascript its typically not a great idea to use dasherize as the properties are harder to access.

Passthroughs

By default if you state nothing then all properties will be moved across from input to output. If you'd prefer only explicit mapping to be passed over you can achieve this with:

const outputData = TypedMapper<IInput, IOutput>
  .input(input).passthrough(false).map( ... ).convert();

Alternatively you can state a few property names which you will NOT being explicitly mapping but you would like to have passed through:

const outputData = TypedMapper<IInput, IOutput>
  .input(input)
  .passthrough('foo', 'bar')
  .map( ... )
  .convert();

So in the above example the "output" would have whichever explicit properties were mapped as well as the foo and bar properties passed through (other props will not pass through).

Property by Property Mapping

The most important feature of this library is to allow users of the library to do mappings triggered off the output's property names. So, for instance, imagine that our output interface is:

interface IOutput {
  fullName: string;
  userAge: number;
}

and the incoming input was:

{
  name: {
    first: "Bob",
    last: "Marley"
  },
  age: 68
}

Mapping would allow you to do the following:

const outputData = TypedMapper<any, IOutput>
  .input(inputData)
  .map({
    fullName: (i) => i.name.first + " " + i.name.last,
    userAge: 'age'
  })
  .convert();

Two things are happening in this example:

  1. fullName is defined as a function and is passed the inputs property values so that it can construct a value from multiple attributes.
  2. userAge is a straight property to property mapping but not one which fits the "naming conventions" rules stated above.

These two styles can go a long way toward your mapping needs but in the next few sections you'll see a bit more that is possible.

Mapping Arrays

Up to now we've mapped one dictionary type to another but often rather than a single mapping you want to map an array of interface A to an array of interface B. Well that's quite simple:

const outputData = TypedMapper<any, IOutput>
  .inputAsArray(inputData)
  .map({
    fullName: (i) => i.name.first + " " + i.name.last,
    userAge: 'age'
  })
  .convert();

It's exactly the same as the prior code snippet but instead of using input we are using inputAsArray. That's all that's needed. Or is it? For most cases it problem is but there are more advanced mapping use cases which need more.

Aggregates

It is not uncommon to have your inputs aggregated into a property on the output. This can be achieved with the following:

const outputData = TypedMapper<any, IOutput>
  .aggregate(inputData)
  .map({
    averageAge: (set) => set.reduce( ... ) / set.length),
    peopleCount: (set) => set.length,
    /** ... */
  })
  .convert();

It's important to note that when using aggregate() we expect an input array and the output will be a single record (aka, Many → One). Some people will note that this approach isn't bringing a lot more than what you could do with plain old Java/Typescript and you'd be right but it's meant as a simple extention to the mapping API that tends to a pretty standard use case.

All is not lost though for the "I want more" crowd. There are many standard aggregations that you can do outside the box, like so:

const outputData = TypedMapper<any, IOutput>
  .aggregate(inputData)
  .map({
    averageAge: 'Average:age',
    peopleCount: 'Count:id',
    manDays: 'Sum:days',
    names: 'Unique:name',
    /** ... */
  })
  .convert();

This you to quickly state the types of aggregations you'd like using built-in formulas. The syntax is always: [Function]:[property] and the functions supported are:

  • Numeric: Average, Count, Sum, Mean, StdDev
  • String: Unique
  • Boolean: Every, None

Ok so that's nice, right? We're not done yet though. Want to group by something? No problem. How about a distribution? Also easy. Filters? Sure why not.

import { TypedMapper, GroupBy, Distribute, Filter } from 'TypedMapper';
const fn = (i) => i.active === true;
const outputData = TypedMapper<IInput, IAggregate>
  .aggregate(inputData)
  .map({
    ageDistribution: Distribute('Count:age', [0, 30, 50, 80]),
    peopleCount: GroupBy(Filter('Count:id', fn), 'gender'),
  })
  .convert();

Cross Record Mapping

Beyond just aggregation, it is sometimes useful in a normal mapping (aka, Many → Many) to be able to allow mapping functions to see not only the current record but the whole record set. This is possible wherever you are inputting an array with inputAsArray:

const outputData = TypedMapper<any, IOutput>
  .inputAsArray(inputData)
  .map({
    estimate: 'personDays',
    estOfTotal: (i, sum) => i.personDays / sum.reduce( ... )
  })
  .convert();

About

Mapping engine which converts from one data structure to another while working with Typescripts generics to allow for strong typing

Resources

License

Stars

Watchers

Forks

Packages

No packages published