Le Data Mapping Typescript au service de vos requêtes
Vous définissez un modèle de données par l’écriture d’une classe pour chacun des appels que vous faites au back-end ? Ou alors vous utilisez une interface parce que de toute façon “ça change pas grand-chose, on veut juste définir une structure” ?
Laissez-moi vous montrer que vous n’utilisez (peut-être) pas correctement vos classes Typescript 🙂
Utiliser une interface
Il est très facile et intuitif (surtout si l’on a fait beaucoup de POO par le passé) de se dire qu’une interface est le bon moyen de définir ses modèles de données pour typer ses appels à une API back.
Prenons l’exemple d’une API publique qui diffuse des données météorologiques qui nous retourne un JSON :
{
"lat": 48.77,
"lon": -1.67,
"current": {
"dt": 1646318698000,
"srdt": 1646306882000,
"ssdt": 1646347929000,
"temp": 79.21,
"wind_speed": 8.75,
"pressure": 1014,
"humidity": 65,
}
}
Alors vous allez intuitivement créer votre interface :
export interface MeteoData {
dt: number;
srdt: number;
ssdt: number;
temp: number;
wind_speed: number;
pressure: number;
humidity: number;
}
export interface MeteoLocation {
lat: number;
lon: number;
current: MeteoData;
}
Parfait maintenant vous allez pouvoir faire appel à l’API pour obtenir un bel objet typé
const request = await fetch('https://api.openweathermap.org/data/3.0/onecall?city=Rennes');
const rennesMeteo: MeteoLocation = await request.json();
Parfait, et après ?
Et bien après, vous vous dites que plusieurs points intéressants pourraient être améliorés :
- ☐ La température en degrés fahrenheit ? Je la voudrais en celsius
- ☐ Les données de dates sont en timestamps 😒! pour pouvoir faire des traitements dessus vous aimeriez avoir des dates typé en DayJs par exemple
- ☐ Ce serait vraiment cool de pouvoir calculer la température ressentie sans passer par un service tier, non ?
- ☐ Enfin, certains champs ne sont pas nommés de façon très “human-readable“, ca pourrait être bien de les renommer
Evidemment, vous ne pouvez pas modifier l’API publique, ce serait trop facile.
Conversion des données
Commençons par la conversion des degrés fahrenheit en degrés celsius.
Techniquement c’est assez simple, vous allez effectuer la conversion directement à la réception de la réponse et le tour est joué !
const data = await request.json();
const rennesMeteo: MeteoLocation = {
...data,
current: {
...data.current,
temp: (data.current.temp - 32) * 5 / 9
}
};
Bon… c’est pas très propre, mais votre collègue vous dira “Ca vaaaa, c’est juste une température. C’est ca ou on le fait à chaque fois qu’on veut l’afficher !” 🤔
Aller, ça passe pour cette fois, vous êtes de bonne humeur.
- 🗸 Conversion de la température en celsius
Conversion de type
Et pour la conversion de la date en type DayJs ? Vous commencez par mettre à jour votre interface bien sûr, ça va de soit :
export interface MeteoData {
dt: Dayjs;
srdt: Dayjs;
ssdt: Dayjs;
temp: number;
wind_speed: number;
pressure: number;
humidity: number;
}
Parfait, et puis bon, on va faire la même chose que pour la conversion de données, parce qu’on est plus à ça prêt !
const rennesMeteo: MeteoLocation = {
...data,
current: {
...data.current,
dt: dayjs(data.current.dt),
srdt: dayjs(data.current.srdt),
ssdt: dayjs(data.current.ssdt),
temp: (data.current.temp - 32) * 5 / 9
}
};
- 🗸 Conversion des timestamps en DayJs
Ajout de données calculées
Parfait, ca avance bien ! Maintenant on fait comment pour ajouter la température ressentie à notre modèle ? A cette instant vous entendez encore votre collègue Michel à l’autre bout de l’open space vous dire “Nan mais t’ajoute un champs à l’interface et tu le calcul directement après la requête”.
S’en est trop, vous décidez qu’il est temps de refacto un peu ce code et de passer d’une interface à une classe pour pouvoir faire des traitements directement dedans.
C’est parti !
export class MeteoData {
dt: Dayjs;
srdt: Dayjs;
ssdt: Dayjs;
temp: number;
wind_speed: number;
pressure: number;
humidity: number;
get feel_temp(): number {
return 13.12 + (0.6215 * this.temp) - (11.37 * Math.pow(this.wind_speed, 0.16)) + (0.3965 * this.temp * Math.pow(this.wind_speed, 0.16));
}
}
export class MeteoLocation {
lat: number;
lon: number;
current: MeteoData;
}
Fière de vous, vous testez votre code.
console.log(`La température ressenti actuellement est de ${rennesMeteo.current.feel_temp}°C :)`);
// output : La température ressenti actuellement est de undefined°C :)
Undefined ?! Ah, mais bien sûr ! Vous avez, l’espace d’un instant, oublié que l’instanciation de votre constante rennesMeteo comme étant de type statique MeteoLocation n’est pas magique !
Typescript ne fait qu’utiliser le type MeteoLocation, à aucun moment il ne va instancier votre classe a votre place. Votre objet rennesMeteo a perdu son typage après la compilation et est donc de type… Object 😩. Bravo vous utilisez votre classe comme une interface !
⛔Flash info : TypeScript est un langage de compilation, une fois compilé, il ne reste que du Javascript, vous perdez donc toutes informations de typage.
Vous ne vous démontez pas et vous décidez d’instancier vos classes. Pour ca vous ajoutez un constructeur à vos deux classes pour faire le boulot.
export class MeteoData {
dt: Dayjs;
srdt: Dayjs;
ssdt: Dayjs;
temp: number;
wind_speed: number;
pressure: number;
humidity: number;
constructor(obj: Partial<MeteoData> = {}) {
Object.assign(this, obj);
}
get feel_temp(): number {
return 13.12 + (0.6215 * this.temp) - (11.37 * Math.pow(this.wind_speed, 0.16)) + (0.3965 * this.temp * Math.pow(this.wind_speed, 0.16));
}
}
export class MeteoLocation {
lat: number;
lon: number;
current: MeteoData;
constructor(obj: Partial<MeteoLocation> = {}) {
Object.assign(this, obj);
}
}
const rennesMeteo: MeteoLocation = new MeteoLocation({
...data,
current: new MeteoData({...})
});
...
// output: La température ressenti actuellement est de 28.047154208621137°C :)
Génial ! A présent on peut ajouter des attributs calculés à notre modèle de données !
- 🗸 Récupération de la température ressentie
Renommage des attributs
On est presque au bout de notre labeur. Maintenant vous voudriez renommer certains attributs pour qu’ils soient plus compréhensibles et pourquoi pas en camelCase pour respecter la convention de votre code.
Votre bientôt-plus-pote-mais-néanmois-collègue Michel vient vous voir avec le sourire : “T’en fait pas je viens de faire une Pull-Request sur le sujet, c’est un petit bijou tu va voir !”
Vous ne tenez plus face à ce teaser et décidez d’aller voir ça !
export class MeteoData {
dateTime: Dayjs;
sunriseDateTime: Dayjs;
sunsetDateTime: Dayjs;
temperature: number;
windSpeed: number;
pressure: number;
humidity: number;
get feelTemperature(): number { ... }
}
[...]
const rennesMeteo: MeteoLocation = new MeteoLocation({
...data,
current: new MeteoData({
...data.current,
dateTime: dayjs(data.current.dt),
sunriseDateTime: dayjs(data.current.srdt),
sunsetDateTime: dayjs(data.current.ssdt),
temperature: (data.current.temp - 32) * 5 / 9,
windSpeed: data.current.wind_speed
})
});
- 🗸 Renommage des attributs
Bravo, vous venez de faire un data mapping à la main !
Bien sûr ca fonctionne, mais vous commencez à vous dire que si les modèles de données se multiplient, ça va commencer à devenir compliqué de maintenir / comprendre / supporter votre code.
Laissez-moi vous aider en vous présentant une des alternatives de data mapping au service de typescript existante. Je vous présente ts-serializer.
Les décorateurs au service du data mapping
Rappelons que le rôle du décorateur est de modifier, par l’ajout d’une annotation (ex: @modifier() myVar: string;
) le comportement d’une classe, d’une propriété ou d’une fonction. Dans notre cas nous allons nous en servir pour factoriser le mapping de données sur les attributs de classes.
Réécrivons ensemble vos classes en utilisant le data mapping. Commençons par le plus simple : le renommage des attributs.
export class MeteoData {
@JsonProperty('dt')
dateTime: number;
@JsonProperty('srdt')
sunriseDateTime: number;
@JsonProperty('ssdt')
sunsetDateTime: number;
@JsonProperty('temp')
temperature: number;
@JsonProperty('wind_speed')
windSpeed: number;
@JsonProperty()
pressure: number;
@JsonProperty()
humidity: number;
get feelTemperature(): number {
return 13.12 + (0.6215 * this.temperature) - (11.37 * Math.pow(this.windSpeed, 0.16)) + (0.3965 * this.temperature * Math.pow(this.windSpeed, 0.16));
}
}
export class MeteoLocation {
@JsonProperty()
lat: number;
@JsonProperty()
lon: number;
@JsonProperty(() => MeteoData)
current: MeteoData;
}
const serializer: Serializer = new Serializer(new Normalizer(), new Denormalizer());
const rennesMeteo: MeteoLocation = serializer.deserialize(MeteoLocation, data);
On obtiens des objets parfaitement typés !
[MeteoLocation]{
"lat": 48.77,
"lon": -1.67,
"current": [MeteoData]{
"dateTime": 1646318698000,
"sunriseDateTime": 1646306882000,
"sunsetDateTime": 1646347929000,
"temperature": 79.21,
"windSpeed": 8.75,
"pressure": 1014,
"humidity": 65
}
}
Génial ! Notre objet et son enfant a bien été instancié avec leur classe respective sans rien avoir à faire ! En une seule fois nous avons résolu les problèmes de renommage et d’ajout de données calculées. Cerise sur le gâteau, toutes les informations de mapping sont disponibles dans le modèle et sont donc bien plus lisibles et accessibles !
Chantilly sur la cerise, la conversion est bi directionnel et peut être inversé pour renvoyer un objet à l’API avec le modèle qu’il attend !
Spéculoos sur la chantilly, si l’API décide de changer son contrat d’interface, vous n’avez que votre configuration de mapping à modifier ! 🤩
// Notre requête historique
const request = await fetch('https://api.openweathermap.org/data/3.0/onecall?city=Rennes');
const data = await request.json();
// Instanciation de notre serializer
const serializer: Serializer = new Serializer(new Normalizer(), new Denormalizer());
// Deserialisation de nos données en objet **MeteoLocation**
const rennesMeteo: MeteoLocation = serializer.deserialize(MeteoLocation, data);
// ...Divers traitement...
// Transformation inverse d'un objet **MeteoLocation** en **Object** simple pour un potentiel POST à l'API
const requestData: any = serializer.serialize(rennesMeteo);
“Ok Jamie, mais tu as pas oublier quelque chose ?
— euh non je vois pas ?!
— Et bien là on a perdu la conversion de donnée !”
Data converters
Bien sûr tout à été prévu ! 😉 Nous allons pouvoir créer des classes en charge de convertir les données de façon bi-directionnel.
export class FahrenheitToCelsiusConverter implements Converter<number, number> {
fromJson(value: number): number {
return (value - 32) * 5 / 9;
}
toJson(value: number): number {
return (value * 9 / 5) + 32;
}
}
export class DayjsDateTimeConverter implements Converter<Dayjs, number> {
fromJson(date: number): Dayjs {
return dayjs(date);
}
toJson(date: Dayjs): number {
return date.date();
}
}
export class MeteoData {
@JsonProperty({field: 'dt', customConverter: () => DayjsDateTimeConverter})
dateTime: Dayjs;
@JsonProperty({field: 'srdt', customConverter: () => DayjsDateTimeConverter})
sunriseDateTime: Dayjs;
@JsonProperty({field: 'ssdt', customConverter: () => DayjsDateTimeConverter})
sunsetDateTime: Dayjs;
@JsonProperty({field: 'temp', customConverter: () => FahrenheitToCelsiusConverter})
temperature: number;
[...]
}
On vient de créer deux custom converters qui nous permettent de passer d’une unité de température à une autre et inversement.
De la même façon nous allons pouvoir profiter des avantages de la librairie DayJs dans le reste du code sans avoir à se préoccuper de la compatibilité avec un potentiel POST au serveur comme le custom converter se charge de faire la conversion en timestamps à notre place.
C’est également important de constater que les convertisseurs de données peuvent être réutilisées voir mutualisées dans une librairie externe.
Encore une fois, la lisibilité est sans commune mesure avec notre précédent code, toutes les informations de mapping sont mises au plus proche du modèle de données.
Bonus : intégration avec Angular
Une sur-couche à la librairie ts-serializer à été développée pour facilité son intégration à Angular ngx-serializer.
Elle vous permettra d’injecter directement le service de serialisation dans vos services d’appel à API.
@Injectable({...})
export class MeteoService {
public static RESOURCE = `https://api.openweathermap.org/data/3.0/onecall`;
constructor(
private readonly httpClient: HttpClient,
private readonly serializer: NgxSerializerService
) { }
getMeteo(city: string): Observable<MeteoLocation> {
return this.httpClient.get<MeteoLocation>(`${MeteoService.RESOURCE}?city=${city}`).pipe(
map((data: any) => this.serializer.deserialize(MeteoLocation, data))
)
}
Conclusion
Le data mapping n’a maintenant plus aucun secret pour vous. J’espère vous avoir convaincu que, hors cas particulier, la création d’interface pour un modèle de données de communication API/client est une sous-utilisation du potentiel offert par TypeScript.
Pour peu que vous utilisiez les bons outils, à l’image de la librairie ts-serializer, vous gagnerez un temps considérable en écriture et en maintenance de vos data models.
N’hesitez pas à partager cet article à Michel, il en a besoin.