@genese/mapper
v2.1.0
Published
<p align="center"> <img src="https://raw.githubusercontent.com/geneseframework/mapper/develop/docs/logo-genese-150x150.png" alt="genese logo"> </p>
Downloads
12
Maintainers
Readme
@genese/mapper
Maps objects of unknown type into the required type.
@genese/mapper
is a part of the @genese tools suite.
Basic usage
With @genese/mapper
, you can transform untyped javascript objects into safe typed objects.
@genese/mapper exposes only one method, the create()
method.
- Example 1 : creation of a typed object
export class Person {
name: string;
hello(): void {
console.log(`Hello ${this.name} !`);
}
}
const data = {name: 'John'};
const person: Person = create(Person, data); // person is a Person object
person.hello(); // log : 'Hello John !'
This is equivalent to :
const person: Person = new Person();
person.name = data.name;
- Example 2 : creation of a more complex object
Now, assume that Person
is a little more complex :
export class Person {
age: number;
cat: Cat;
firstname: string;
lastname: string;
hello(): void {
console.log(`Hello ${this.name} !`);
}
}
export class Cat {
name: string;
meaow(): void {
console.log(`Meaow !`);
}
}
const data = {
age: 20,
cat: {
name: 'Molly'
},
firstname: 'John',
lastname: 'Doe',
};
In this case, it would be sufficiently long to create manually the Person
object :
const cat: Cat = new Cat();
cat.name = data.cat.name;
const person: Person = new Person();
person.age = data.age;
person.firstname = data.firstname;
person.lastname = data.lastname;
person.hello(); // => logs 'Hello John !'
person.cat.meaow(); // => logs 'Meaow !'
With @genese/mapper
, you can do it in one line :
const person: Person = create(Person, data); // Person object which contains a Cat object
person.hello(); // log: 'Hello John !'
person.cat.meaow(); // log: 'Meaow !'
The data
object may be as complex as you want, you will still need only one line to create a real object, including nested objects if necessary.
- Example 3 : validation of the data shape
The above usage simplifies the creation of known objects, the real power of @genese/mapper
is to create safe typed objects even when you don't know the data
value or even its shape.
Assume that you receive some data
with unknown value or shape, like on http requests. You need to check the data
shape and verify if its value respects your DTO contract :
interface PersonDto {
name: string;
skills: string[];
}
Without @genese/mapper
, your controller in the backend could be written like this (example with NestJs) :
@Post()
addPerson(@Body() data: PersonDto) {
if (isValid(data)) {
addNewPersonToDataBase(data); // do some stuff
}
}
isValid(data: any): data is PersonDto {
return data
&& typeof data.name === 'string'
&& Array.isArray(data.skills)
&& data.skills.every(d => typeof d === 'string');
}
With @genese/mapper, you could simply do that :
@Post()
addPerson(@Body() data: PersonDto) {
if (create('PersonDto', data)) { // create('PersonDto', data) is a PersonDto object if data is correct, undefined if not
addNewPersonToDataBase(data);
}
}
The create()
method checks everything for you. If data value respects the contract of the interface PersonDto
the create()
method will return the data
value. If data is incorrect, it will return undefined
.
This method can be used with primitives, arrays, tuples, classes, interfaces, enums and types.
Table of Contents
- Installation
- Configuration
- Start
- The create() method
- Configuration details
- Limitations
- Warnings
Installation
Install the npm module:
npm install @genese/mapper
Configuration
package.json
Add this line to your package.json
:
{
"scripts": {
"mapper": "typeLiteralNode node_modules/@genese/mapper/dist/init/init.js"
}
}
The instruction npm run mapper
must be called before executing your own code.
- Example with Angular application :
{
"scripts": {
"mapper": "typeLiteralNode node_modules/@genese/mapper/dist/init/init.js",
"start": "npm run mapper && ng serve"
}
}
- Example if you run your code in NodeJs environment, like in backend :
{
"scripts": {
"mapper": "typeLiteralNode node_modules/@genese/mapper/dist/init/init.js",
"start": "npm run mapper && ts-typeLiteralNode main.ts"
}
}
This constraint is due to the fact that in the first phase of its process, @genese/mapper
analyzes the code of your project and creates some temporary files. These files will be used later, when the create()
method will be called.
geneseconfig.ts
A file called geneseconfig.ts
may be added at the root of your project. If there is no geneseconfig.ts
file at the root of your project, @genese/mapper
will use configuration by default.
export const geneseConfig: GeneseConfig = {
mapper: {}
}
For now, let's keep the property mapper
empty. We will see configuration details later.
angular.json
Angular outputs warnings for CommonJS modules. To disable these warnings, you can add the @genese/mapper
module to the allowCommonJsDependencies
option in the build
options located in teh angular.json
file :
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"allowedCommonJsDependencies": [
"@genese/mapper"
]
...
}
...
},
Start
Let's try @genese/mapper
in a simple example, assuming that you want to run your code in NodeJs environment.
For that, install the ts-node
module :
npm install ts-typeLiteralNode
Create a file called mapper-example.ts
at the root of your project with this code :
import { create } from '@genese/mapper/dist/create/main';
export class Person {
name: string;
hello() {
console.log(`Hello ${this.name} !`)
}
}
const person = create(Person, {name: 'Léa'});
console.log('PERSON :', person); // log: PERSON Person {name: 'Léa'}
person.hello(); // log: Hello John !
Of course, the create()
method could be called from another file.
The create() method
In many cases, you don't know the value of data
, like on http requests results. You must check if data exists, if it respects the contract (ie: has a correct shape), remove the eventual unnecessary properties and eventually add default values.
You can do all of this in one line with the create()
method. In case of irrelevant data properties, wrong values format or missing properties, the create()
method has specific behavior which we will now explain. In some cases, this behavior can be modified thanks to geneseconfig.ts
file.
We will now explain this behavior for different cases :
Primitives
Basic behavior with primitives
You want to check if the received data is a primitive (string, number or boolean) :
const foo: string = create('string', data); // foo equals data if data is a string, undefined if not.
Please note the quotes around the word string
. The only cases where you can omit the quotes are for classes, primitive constructors and tuples, like create(Person, data)
, create(String, data)
or create(['string', 'number'], data)
.
- Examples :
create('string', 'a'); // 'a'
create('number', 1); // 1
create('string', 1); // undefined
The castStringsAndNumbers option
In the last example, the result is equal to undefined
because 1
is not a string
. However, you could prefer to identify strings and numbers and receive '1'
instead of undefined
. For that, you can change the behavior of the create()
method by adding a specific option :
create('string', 1, {castStringsAndNumbers: true}); // '1'.
If you want to use this behavior for all your project, you can do it with the geneseconfig.ts
file :
export const geneseConfig: GeneseConfig = {
mapper: {
behavior: {
castStringsAndNumbers: true,
},
}
}
create('string', 1); // '1'
Now foo
equals '1'
if data
equals 1
, without adding option inside the create()
method.
Conversely, you can cast strings
and numbers
for all your project with the config above, and not cast them for a specific call to the create()
method by adding to it the option castStringsAndNumbers: false
.
Literal primitives
In the below examples, 'a'
and 1
are interpreted as a literal elements : @genese/mapper
will return data
value if data
is equal to this literal element, and undefined
if not.
create('a', 'a'); // 'a'
create('a', 'b'); // undefined
create(1, 1); // 1
create(1, '1'); // undefined
create(1, '1', {castStringsAndNumbers: true}); // 1
create(1, '1'); // undefined
Arrays
Arrays are mapped trivially like this :
create('string[]', ['blue', 'white']); // ['blue', 'white'];
create('string[]', ['blue', 2]); // ['blue', undefined];
create('string[]', ['blue', 2], {castStringsAndNumbers: true}); // ['blue', '2'];
Classes
Assume that you want to cast data
in a safe typed instance :
Basic behavior with classes
export class Person {
age: number;
name: string;
hello(): void {
console.log(`Hello ${this.name} ! You are ${this.age} years old.`);
}
}
const data = {name: 'John', age: 20};
const person: Person = create(Person, data); // person is a Person object
person.hello(); // log: 'Hello John ! You are 20 years old.'
If
data
is undefined or is not an object,create()
will returnundefined
.If
data
is an object,create()
will return an instance ofPerson
.If the property
name
is equal toundefined
indata
, the value ofname
will be equal toundefined
inperson
.If the property
name
does not exist indata
, the propertyname
will not exist inperson
if there is no default value.Examples :
create(Person, undefined); // undefined;
create(Person, {}); // Person {};
create(Person, {name: undefined, age: 20}); // Person {name: undefined; age: 20};
create(Person, {age: 20}); // Person {age: 20};
Caution
Contrary to interfaces, types or enums, a class must be exported to be able to be used by @genese/mapper
.
Irrelevant properties
If a property exists in data
but not in Person
, this property will be removed :
const data = {name: 'John', age: 20};
create(Person, data); // Person {name: 'John'}
Properties with wrong type
If a data
property has a wrong type, the value of this property in the mapped object will be equal to undefined
, or to the default value if exists.
const data = {name: 20};
create(Person, data); // Person {name: undefined}
With wrong type but identifying strings and numbers :
const data = {age: '20'};
create(Person, data, {castStringsAndNumbers: true}); // Person {age: 20}
With default property :
export class Person {
name: string = 'John';
}
const data = {name: 3};
create(Person, data); // Person {name: 'John'}
Constructor parameters
If your class has constructor parameters, the instance will be created by replacing each parameter by the corresponding data
value or by undefined
if this value does not exist in data
.
Without default value :
export class Person {
name: string;
constructor(name: string) {
this.name = name;
}
}
create(Person, {name: 'John'}); // Person {name: 'John'}
create(Person, {}); // Person {}
With default value :
export class Person {
name: string;
constructor(name: string = 'John') {
this.name = name;
}
}
create(Person, {name: 'Jane'}); // Person {name: 'Jane'}
create(Person, {name: undefined}); // Person {name: 'John'}
create(Person, {}); // Person {name: 'John'}
Indexable types
Indexable types are usable with @genese/mapper
:
export class Person {
name: string;
[key: string]: string;
constructor(name: string) {
this.name = name;
}
}
create(Person, {name: 'John', role: 'user'}); // Person {name: 'John', role: 'user'}
Nested classes
Properties with class
types are mapped as instances of their corresponding type :
export class Person {
name: string;
cat: Cat;
}
export class Cat {
name: string;
meaow(): void {
console.log(`Meaow !`);
}
}
const data = {
name: 'John',
cat: {
name: 'Molly'
}
};
const person: Person = create(Person, data); // Person {name: 'John', cat: Cat {name: 'Molly'}}
const molly: Cat = person.cat; // Cat {name: 'Molly'}
molly.meaow(); // log: Meaow !
Heritage
@genese/mapper
takes into account the notion of heritage :
export class Person {
name: string;
constructor(name: string) {
this.name = name;
}
hello(): void {
console.log(`Hello ${this.name} !`);
}
}
export class User extends Person {
role: string;
constructor(role: string, name: string) {
super(name);
this.role = role;
}
}
const data = {
name: 'John',
role: 'user',
};
const user: User = create(User, data); // user === User {name: 'John', role: 'user'}
user.hello(); // log: Hello John !
Abstract classes
Abstract classes can't be instantiated. If you try it, @genese/mapper
will return undefined
.
export abstract class AbstractPerson {
name: string;
}
const data = {name: 'John'};
create(AbstractPerson, data); // undefined
Literal objects
If a property is typed with a literal object and if the corresponding data property don't have the same keys than the literal object, the result will be equal to undefined
.
If data
has the same keys, these keys will be mapped as usual.
export class Person {
address: {
country: string,
city: string,
};
}
const foo = {address: {country: 'Spain', city: 'Barcelona'}};
create(Person, foo); // Person {address: {country: 'Spain', city: 'Barcelona'}}
const bar = {address: {country: 'Spain', street: 'Ramblas'}};
create(Person, bar); // Person {address: undefined}
const baz = {address: {country: 'Spain', city: 23}};
create(Person, baz); // Person {address: {country: 'Spain', city: undefined}}
Interfaces
The interfaces are treated as classes, with the specificities due to interfaces: the respect of the contract.
Please note that contrary to classes, the name of the interface must be surrounded by quotes.
export interface Person {
name: string;
age: number;
city?: string
}
create('Person', {name: 'John', age: 20, city: 'Milano'}); // Person {name: 'John', age: 20, city: 'Milano'}
create('Person', {name: 'John', age: 20}); // Person {name: 'John', age: 20}
create('Person', {name: 'John'}); // undefined
Enums
If data
value is one of the enum values, @genese/mapper
will return this value. If not, it will return undefined
.
As for interfaces or types, the name of the enum must be surrounded by quotes.
export enum Color {
WHITE = 'White',
BLACK = 'Black',
}
create('Color', 'White'); // 'White'
create('Color', 'Blue'); // undefined
Tuples
@genese/mapper
checks if the number of elements of data
is the same as in the expected tuple, and if the type of each element is correct. If the number of elements is wrong, it returns undefined
. If the number of elements is correct, @genese/mapper
returns a tuple where each element is mapped as usual.
create(['string', 'string'], ['blue', 'white']); // ['blue', 'white']
create(['string', 'string'], ['blue']); // undefined
create(['string', 'string'], ['blue', 3]); // ['blue', undefined]
Caution :
You can surround tuples by quotes or not, but if you choose to surround them by quotes, the words surrounded by quotes inside the tuples will be understood as literal strings :
create(`[string, string]`, ['blue', 'white']); // ['blue', 'white']
create(`['string', 'string']`, ['blue', 'white']); // [undefined, undefined]
In the same way, classes, interfaces or types inside a quoted tuple can't be surrounded by quotes (if they are, they will be understood as literal strings) :
export class Person {
name: string;
}
create(`[Person, Person]`, [{name: 'John'}, {name: 'Jane'}]); // [Person {name: 'John'}, Person {name: 'Jane'}]
create(`['Person', 'Person']`, [{name: 'John'}, {name: 'Jane'}]);// [undefined, undefined]
Types
Generally speaking, the @genese/mapper
behavior for types is close to its behavior for interfaces : if data
respects the contract defined by the expected type, @genese/mapper
will return the data
value. If not, it will return undefined
.
As for interfaces or enums, the name of the type must be surrounded by quotes.
Literal types
In case of literal types, @genese/mapper
only checks if data
value is equal to the literal value of the type :
export type LiteralString = 'blue';
create('LiteralString', 'blue'); // 'blue'
create('LiteralString', 'white'); // undefined
Union types
For union types, @genese/mapper
checks if data
shape respects one of the types of the union :
export type StringOrNumber = string | number;
create('StringOrNumber', '2'); // '2'
create('StringOrNumber', 2); // 2
create('StringOrNumber', {name: 'John'}); // undefined
Types defined by classes
If a type is corresponding to a class, @genese/mapper
will interpret this type as its relative class. That means that you will be able to use the eventual method of this class :
export class Person {
name: string;
hello(): void {
console.log(`Hello ${this.name} !`);
}
}
export type TPerson = Person;
const person: Person = create('TPerson', {name: 'John'}); // Person {name: 'John'}
person.hello(); // log : 'Hello John !'
Dates
@genese/mapper
is able to interpret dates. The date type may be written with quotes or without, as Date
constructor :
create('Date', '2021-02-19T17:36:53.999Z'); // new Date('2021-02-19T17:36:53.999Z')
create(Date, '2021-02-19T17:36:53.999Z'); // new Date('2021-02-19T17:36:53.999Z')
The usage of Date
constructor may be interesting because @genese/mapper
will check at compile time if the type of the variable is correct :
const foo: number = create('Date', '2021-02-19T17:36:53.999Z'); // no error detected
const bar: number = create(Date, '2021-02-19T17:36:53.999Z'); // error detected
Configuration details
Basics
We saw on the basic configuration section that a file called geneseconfig.ts
can be added at the root of your project :
export const geneseConfig: GeneseConfig = {
mapper: {}
}
We also saw the possibility to add specific behavior for strings and numbers in a previous section :
export const geneseConfig: GeneseConfig = {
mapper: {
castStringsAndNumbers: true // strings will be cast in numbers and inversely
}
}
Now, let's look at other configuration options.
Options 'include' and 'tsconfigs'
By default, @genese/mapper
analyzes all the files corresponding to the tsconfig.json
located at the root of your project. You can change this behavior by two ways :
- Include some specific files
export const geneseConfig: GeneseConfig = {
mapper: {
include: ['path/to/one/file.ts', 'path/to/other-file.ts']
}
}
- Use different
tsconfig.json
files
export const geneseConfig: GeneseConfig = {
mapper: {
tsconfigs: ['path/to/tsconfig.json', 'path/to/tsconfig.app.json']
}
}
Caution
If the tsconfigs
option exists and is not empty, @genese/mapper
will not use the default tsconfig.json
file. If you want to use the default tsconfig.json
and another one, you must include both in the tsconfigs
property.
export const geneseConfig: GeneseConfig = {
mapper: {
tsconfigs: ['tsconfig.json', 'path/to/other/tsconfig.json']
}
}
Limitations
Some features are still in progress and will be added in the future :
- Literal objects
For now, literal objects are mapped when they type some property of a given class, but you can't use them directly, outside any class :
export class Person {
address: {
country: string,
city: string,
}
}
create(Person, {address: {country: 'Italy', city: 'Roma'}}); // runs
create(`{country: string, city: string}`, {country: 'Italy', city: 'Roma'}); // fails
- Generic types
Generic types are actually not supported :
export class Person<T> {
property: T
}
create('Person<string>', {property: 'a'}); // fails
- Types defined by functions
Function types are not yet supported by @genese/mapper
.
export type FunctionTypeSpec = () => string;
create('FunctionTypeSpec', () => 'a'); // fails
- New instances as default values
For now, @genese/mapper
will fail when the default value of a property is given by something like new SomeClass()
:
export class Cat {
name: string;
}
export class Person {
cat: Cat = new Cat();
}
create('Person', {cat: {name: 'Molly'}}); // fails
Warnings
@genese/mapper
throws different warnings :
- unknown target "...". @genese/mapper interpreted it as "any" and data will be set "as is" in the mapped response.
@genese/mapper
was not able to find the definition of a given type. In this case, @genese/mapper
will not check the data
shape or value and will simply copy-paste it in the result of the create()
method.
Possibilities :
- there is a typing error in your call to the
create()
method
create('strring', '2') // Warning
- your call is correct but
@genese/mapper
was not able to find the targeted type.
create('SomeUnknownClass', {name: 'John'}); // Warning
In this case, please verify if the file corresponding to SomeUnknownClass
exists in your project (ie: is included in your typescript.json
). If not, add it in the geneseconfig.ts
configuration.
- different elements "..." are declared in your project.
You should not have two declarations with the same name in your project (two classes with the same name, one class with the same name as a type, etc.)
- Impossible to map this instance. Did you export it ?
To be able to create a new instance, @genese/mapper
must be able to import it in a temporary file. That's why you must export classes used by @genese/mapper
, even if your call to the create()
method is in the same file as the definition of your class.
At the opposite, the keyword export
is not mandatory for interfaces, types or enums.
- Warning: ... depends on '@genese/mapper/dist/create/main'. CommonJS or AMD dependencies can cause optimization bailouts.
You can remove this Angular warning by configuring CommonJs dependencies.