Explorez les types exacts en TypeScript pour une correspondance stricte de la forme des objets, évitant les propriétés inattendues et assurant la robustesse du code.
Types Exacts en TypeScript : Correspondance Stricte de la Forme des Objets pour un Code Robuste
TypeScript, un sur-ensemble de JavaScript, apporte le typage statique au monde dynamique du développement web. Bien que TypeScript offre des avantages significatifs en termes de sécurité de type et de maintenabilité du code, son système de typage structurel peut parfois entraîner un comportement inattendu. C'est là que le concept de "types exacts" entre en jeu. Bien que TypeScript n'ait pas de fonctionnalité intégrée explicitement nommée "types exacts", nous pouvons obtenir un comportement similaire grâce à une combinaison de fonctionnalités et de techniques TypeScript. Cet article de blog explorera comment appliquer une correspondance plus stricte de la forme des objets en TypeScript pour améliorer la robustesse du code et prévenir les erreurs courantes.
Comprendre le Typage Structurel de TypeScript
TypeScript utilise le typage structurel (aussi connu sous le nom de duck typing), ce qui signifie que la compatibilité des types est déterminée par les membres des types, plutôt que par leurs noms déclarés. Si un objet possède toutes les propriétés requises par un type, il est considéré comme compatible avec ce type, même s'il possède des propriétés supplémentaires.
Par exemple :
interface Point {
x: number;
y: number;
}
const myPoint = { x: 10, y: 20, z: 30 };
function printPoint(point: Point) {
console.log(`X: ${point.x}, Y: ${point.y}`);
}
printPoint(myPoint); // Cela fonctionne bien, même si myPoint a la propriété 'z'
Dans ce scénario, TypeScript permet à `myPoint` d'être passé à `printPoint` car il contient les propriétés `x` et `y` requises, même s'il a une propriété `z` supplémentaire. Bien que cette flexibilité puisse être pratique, elle peut aussi conduire à des bogues subtils si vous passez par inadvertance des objets avec des propriétés inattendues.
Le Problème avec les Propriétés en Excès
La souplesse du typage structurel peut parfois masquer des erreurs. Considérez une fonction qui attend un objet de configuration :
interface Config {
apiUrl: string;
timeout: number;
}
function setup(config: Config) {
console.log(`API URL: ${config.apiUrl}`);
console.log(`Timeout: ${config.timeout}`);
}
const myConfig = { apiUrl: "https://api.example.com", timeout: 5000, typo: true };
setup(myConfig); // TypeScript ne se plaint pas ici !
console.log(myConfig.typo); // affiche true. La propriété supplémentaire existe silencieusement
Dans cet exemple, `myConfig` a une propriété supplémentaire `typo`. TypeScript ne lève pas d'erreur car `myConfig` satisfait toujours l'interface `Config`. Cependant, la faute de frappe n'est jamais détectée, et l'application pourrait ne pas se comporter comme prévu si la faute de frappe était censée être `typoo`. Ces problèmes apparemment insignifiants peuvent se transformer en maux de tête majeurs lors du débogage d'applications complexes. Une propriété manquante ou mal orthographiée peut être particulièrement difficile à détecter lorsqu'il s'agit d'objets imbriqués dans d'autres objets.
Approches pour Appliquer les Types Exacts en TypeScript
Bien que les vrais "types exacts" ne soient pas directement disponibles en TypeScript, voici plusieurs techniques pour obtenir des résultats similaires et appliquer une correspondance plus stricte de la forme des objets :
1. Utiliser les Assertions de Type avec `Omit`
Le type utilitaire `Omit` vous permet de créer un nouveau type en excluant certaines propriétés d'un type existant. Combiné à une assertion de type, cela peut aider à prévenir les propriétés en excès.
interface Point {
x: number;
y: number;
}
const myPoint = { x: 10, y: 20, z: 30 };
// Créer un type qui inclut uniquement les propriétés de Point
const exactPoint: Point = myPoint as Omit & Point;
// Erreur : Le type '{ x: number; y: number; z: number; }' n'est pas assignable au type 'Point'.
// Le littéral d'objet ne peut spécifier que des propriétés connues, et 'z' n'existe pas dans le type 'Point'.
function printPoint(point: Point) {
console.log(`X: ${point.x}, Y: ${point.y}`);
}
//Correction
const myPointCorrect = { x: 10, y: 20 };
const exactPointCorrect: Point = myPointCorrect as Omit & Point;
printPoint(exactPointCorrect);
Cette approche lève une erreur si `myPoint` a des propriétés qui ne sont pas définies dans l'interface `Point`.
Explication : `Omit
2. Utiliser une Fonction pour Créer des Objets
Vous pouvez créer une fonction de fabrique (factory function) qui n'accepte que les propriétés définies dans l'interface. Cette approche fournit une vérification de type forte au moment de la création de l'objet.
interface Config {
apiUrl: string;
timeout: number;
}
function createConfig(config: Config): Config {
return {
apiUrl: config.apiUrl,
timeout: config.timeout,
};
}
const myConfig = createConfig({ apiUrl: "https://api.example.com", timeout: 5000 });
//Ceci ne compilera pas :
//const myConfigError = createConfig({ apiUrl: "https://api.example.com", timeout: 5000, typo: true });
//L'argument de type '{ apiUrl: string; timeout: number; typo: true; }' n'est pas assignable au paramètre de type 'Config'.
// Le littéral d'objet ne peut spécifier que des propriétés connues, et 'typo' n'existe pas dans le type 'Config'.
En retournant un objet construit uniquement avec les propriétés définies dans l'interface `Config`, vous vous assurez qu'aucune propriété supplémentaire ne peut s'infiltrer. Cela rend la création de la configuration plus sûre.
3. Utiliser les Gardes de Type (Type Guards)
Les gardes de type sont des fonctions qui affinent le type d'une variable dans une portée spécifique. Bien qu'elles n'empêchent pas directement les propriétés en excès, elles peuvent vous aider à les vérifier explicitement et à prendre les mesures appropriées.
interface User {
id: number;
name: string;
}
function isUser(obj: any): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj && typeof obj.id === 'number' &&
'name' in obj && typeof obj.name === 'string' &&
Object.keys(obj).length === 2 //vérifie le nombre de clés. Remarque : fragile et dépend du nombre exact de clés de User.
);
}
const potentialUser1 = { id: 123, name: "Alice" };
const potentialUser2 = { id: 456, name: "Bob", extra: true };
if (isUser(potentialUser1)) {
console.log("Utilisateur valide :", potentialUser1.name);
} else {
console.log("Utilisateur invalide");
}
if (isUser(potentialUser2)) {
console.log("Utilisateur valide :", potentialUser2.name); //N'atteindra pas ce point
} else {
console.log("Utilisateur invalide");
}
Dans cet exemple, la garde de type `isUser` vérifie non seulement la présence des propriétés requises mais aussi leurs types et le nombre *exact* de propriétés. Cette approche est plus explicite et vous permet de gérer les objets invalides avec élégance. Cependant, la vérification du nombre de propriétés est fragile. Chaque fois que `User` gagne ou perd des propriétés, la vérification doit être mise à jour.
4. Tirer parti de `Readonly` et `as const`
Alors que `Readonly` empêche la modification des propriétés existantes, et que `as const` crée un tuple ou un objet en lecture seule où toutes les propriétés sont profondément en lecture seule et ont des types littéraux, ils peuvent être utilisés pour créer une définition et une vérification de type plus strictes lorsqu'ils sont combinés avec d'autres méthodes. Cependant, aucun des deux n'empêche les propriétés en excès par lui-même.
interface Options {
width: number;
height: number;
}
//Créer le type Readonly
type ReadonlyOptions = Readonly;
const options: ReadonlyOptions = { width: 100, height: 200 };
//options.width = 300; //erreur : Impossible d'assigner à 'width' car c'est une propriété en lecture seule.
//Utilisation de as const
const config = { api_url: "https://example.com", timeout: 3000 } as const;
//config.timeout = 5000; //erreur : Impossible d'assigner à 'timeout' car c'est une propriété en lecture seule.
//Cependant, les propriétés en excès sont toujours autorisées :
const invalidOptions: ReadonlyOptions = { width: 100, height: 200, depth: 300 }; //pas d'erreur. Autorise toujours les propriétés en excès.
interface StrictOptions {
readonly width: number;
readonly height: number;
}
//Ceci va maintenant générer une erreur :
//const invalidStrictOptions: StrictOptions = { width: 100, height: 200, depth: 300 };
//Le type '{ width: number; height: number; depth: number; }' n'est pas assignable au type 'StrictOptions'.
// Le littéral d'objet ne peut spécifier que des propriétés connues, et 'depth' n'existe pas dans le type 'StrictOptions'.
Cela améliore l'immutabilité, mais ne prévient que la mutation, pas l'existence de propriétés supplémentaires. Combiné avec `Omit`, ou l'approche par fonction, cela devient plus efficace.
5. Utiliser des Bibliothèques (ex: Zod, io-ts)
Des bibliothèques comme Zod et io-ts offrent de puissantes capacités de validation de type à l'exécution et de définition de schémas. Ces bibliothèques vous permettent de définir des schémas qui décrivent précisément la forme attendue de vos données, y compris la prévention des propriétés en excès. Bien qu'elles ajoutent une dépendance à l'exécution, elles offrent une solution très robuste et flexible.
Exemple avec Zod :
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
});
type User = z.infer;
const validUser = { id: 1, name: "John" };
const invalidUser = { id: 2, name: "Jane", extra: true };
const parsedValidUser = UserSchema.parse(validUser);
console.log("Utilisateur Valide Parsé :", parsedValidUser);
try {
const parsedInvalidUser = UserSchema.parse(invalidUser);
console.log("Utilisateur Invalide Parsé :", parsedInvalidUser); // Ceci ne sera pas atteint
} catch (error) {
console.error("Erreur de Validation :", error.errors);
}
La méthode `parse` de Zod lèvera une erreur si l'entrée n'est pas conforme au schéma, empêchant ainsi efficacement les propriétés en excès. Cela fournit une validation à l'exécution et génère également des types TypeScript à partir du schéma, assurant la cohérence entre vos définitions de type et votre logique de validation à l'exécution.
Meilleures Pratiques pour Appliquer les Types Exacts
Voici quelques meilleures pratiques à considérer lors de l'application d'une correspondance plus stricte de la forme des objets en TypeScript :
- Choisissez la bonne technique : La meilleure approche dépend de vos besoins spécifiques et des exigences de votre projet. Pour les cas simples, les assertions de type avec `Omit` ou les fonctions de fabrique peuvent suffire. Pour des scénarios plus complexes ou lorsque la validation à l'exécution est requise, envisagez d'utiliser des bibliothèques comme Zod ou io-ts.
- Soyez cohérent : Appliquez l'approche choisie de manière cohérente dans toute votre base de code pour maintenir un niveau uniforme de sécurité de type.
- Documentez vos types : Documentez clairement vos interfaces et vos types pour communiquer la forme attendue de vos données aux autres développeurs.
- Testez votre code : Écrivez des tests unitaires pour vérifier que vos contraintes de type fonctionnent comme prévu et que votre code gère les données invalides avec élégance.
- Considérez les compromis : Appliquer une correspondance plus stricte de la forme des objets peut rendre votre code plus robuste, mais cela peut aussi augmenter le temps de développement. Pesez les avantages par rapport aux coûts et choisissez l'approche qui a le plus de sens pour votre projet.
- Adoption progressive : Si vous travaillez sur une grande base de code existante, envisagez d'adopter ces techniques progressivement, en commençant par les parties les plus critiques de votre application.
- Préférez les interfaces aux alias de type lors de la définition des formes d'objets : Les interfaces sont généralement préférées car elles prennent en charge la fusion de déclarations, ce qui peut être utile pour étendre les types sur différents fichiers.
Exemples du Monde Réel
Examinons quelques scénarios du monde réel où les types exacts peuvent être bénéfiques :
- Charges utiles des requêtes API : Lors de l'envoi de données à une API, il est crucial de s'assurer que la charge utile est conforme au schéma attendu. L'application de types exacts peut prévenir les erreurs causées par l'envoi de propriétés inattendues. Par exemple, de nombreuses API de traitement des paiements sont extrêmement sensibles aux données inattendues.
- Fichiers de configuration : Les fichiers de configuration contiennent souvent un grand nombre de propriétés, et les fautes de frappe peuvent être courantes. L'utilisation de types exacts peut aider à détecter ces fautes de frappe très tôt. Si vous configurez des emplacements de serveurs dans un déploiement cloud, une faute de frappe dans un paramètre d'emplacement (par exemple, eu-west-1 vs eu-wet-1) deviendra extrêmement difficile à déboguer si elle n'est pas détectée à l'avance.
- Pipelines de transformation de données : Lors de la transformation de données d'un format à un autre, il est important de s'assurer que les données de sortie sont conformes au schéma attendu.
- Files d'attente de messages : Lors de l'envoi de messages via une file d'attente de messages, il est important de s'assurer que la charge utile du message est valide et contient les bonnes propriétés.
Exemple : Configuration de l'Internationalisation (i18n)
Imaginez la gestion des traductions pour une application multilingue. Vous pourriez avoir un objet de configuration comme celui-ci :
interface Translation {
greeting: string;
farewell: string;
}
interface I18nConfig {
locale: string;
translations: Translation;
}
const englishConfig: I18nConfig = {
locale: "en-US",
translations: {
greeting: "Hello",
farewell: "Goodbye"
}
};
//Ceci posera un problème, car une propriété en excès existe, introduisant silencieusement un bogue.
const spanishConfig: I18nConfig = {
locale: "es-ES",
translations: {
greeting: "Hola",
farewell: "Adiós",
typo: "traduction non intentionnelle"
}
};
//Solution : Utiliser Omit
const spanishConfigCorrect: I18nConfig = {
locale: "es-ES",
translations: {
greeting: "Hola",
farewell: "Adiós"
} as Omit & Translation
};
Sans types exacts, une faute de frappe dans une clé de traduction (comme l'ajout d'un champ `typo`) pourrait passer inaperçue, entraînant des traductions manquantes dans l'interface utilisateur. En appliquant une correspondance plus stricte de la forme des objets, vous pouvez détecter ces erreurs pendant le développement et les empêcher d'atteindre la production.
Conclusion
Bien que TypeScript n'ait pas de "types exacts" intégrés, vous pouvez obtenir des résultats similaires en utilisant une combinaison de fonctionnalités et de techniques TypeScript comme les assertions de type avec `Omit`, les fonctions de fabrique, les gardes de type, `Readonly`, `as const`, et des bibliothèques externes comme Zod et io-ts. En appliquant une correspondance plus stricte de la forme des objets, vous pouvez améliorer la robustesse de votre code, prévenir les erreurs courantes et rendre vos applications plus fiables. N'oubliez pas de choisir l'approche qui convient le mieux à vos besoins et d'être cohérent dans son application dans toute votre base de code. En examinant attentivement ces approches, vous pouvez prendre un plus grand contrôle sur les types de votre application et augmenter sa maintenabilité à long terme.