🧩 TypeScript : astuces, bonnes pratiques et types avancés
1. Type vs Interface
En TypeScript, type
et interface
permettent tous deux de décrire la forme d’un objet. Il est tout à fait cohérent d’utiliser les deux dans un même projet, chacun a ses avantages selon le contexte, mais pour garder une certaine cohérence et lisibilité, il est souvent recommandé de choisir l’un ou l’autre en fonction du projet (ou des préférences de l'équipe).
- Interface : idéale pour décrire la forme des objets, extensible par héritage ou déclaration multiple (⚠️ à éviter dans le code métier, voir note ci-dessous).
- Type : plus flexible (unions, intersections, types primitifs, alias, etc.).
// Interface : extensible
interface User {
id: number;
name: string;
}
interface User {
email: string; // Ajout possible par déclaration multiple (⚠️ à éviter)
}
interface UserRole extends User {
role: string;
} // UserRole est une interface qui hérite de User et ajoute un champ role.
// Type : unions, intersections
type ID = number | string;
type UserWithRole = User & { role: string }; // alternative à extends pour les types
Interface | Type |
---|---|
Extensible, héritage, objets | Unions, intersections, alias, primitives |
Déclaration multiple possible | Non extensible par déclaration multiple |
2. Unions et intersections de types
Les unions permettent de définir un type qui peut prendre plusieurs formes : type Role = "admin" | "editor" | "viewer";
.
Les intersections permettent de combiner plusieurs types en un seul : type UserWithRole = User & { role: string };
.
Il est aussi possible d'utiliser une intersection sur des unions pour restreindre davantage les possibilités : type AdminOrEditor = (User & { role: "admin" }) | (User & { role: "editor" });
.
Ici, AdminOrEditor
accepte uniquement les objets User
dont le rôle est "admin"
ou "editor"
(et pas "viewer"
), ce qui permet de combiner la puissance des deux outils. Ces deux outils sont essentiels pour composer des types flexibles et sécuriser vos données.
// Union de types
type Status = "success" | "error" | "loading";
let s: Status = "success";
s = "loading"; // OK
s = "fail"; // Erreur TS
// Intersection de types
type Address = { city: string };
type User = { id: number; name: string };
type UserWithAddress = User & Address;
const u: UserWithAddress = { id: 1, name: "Alice", city: "Paris" };
// Intersection sur une union
// Variante 1 : discriminée
// type AdminOrEditor = (User & { role: "admin" }) | (User & { role: "editor" });
// Variante 2 : plus simple et souvent suffisante
type AdminOrEditor = User & { role: "admin" | "editor" };
const admin: AdminOrEditor = { id: 1, name: "Alice", role: "admin" }; // OK
const editor: AdminOrEditor = { id: 2, name: "Bob", role: "editor" }; // OK
const viewer: AdminOrEditor = { id: 3, name: "Eve", role: "viewer" }; // Erreur TS
// La première forme permet de distinguer chaque rôle si d'autres propriétés varient selon le rôle.
// La seconde est plus concise si seule la valeur du rôle change.
3. Utiliser les types utilitaires intégrés
TypeScript fournit des types prêts à l’emploi pour transformer vos définitions et manipuler les objets de façon typée.
type User = {
id: number;
name: string;
email: string;
}
// Partial rend tout optionnel
// Résultat : { id?: number; name?: string; email?: string }
type UserUpdate = Partial<User>;
// Pick extrait seulement certaines propriétés
// Résultat : { id: number; name: string }
type UserPublic = Pick<User, 'id' | 'name'>;
// Définition du type ID utilisé comme clé pour Record
// ID peut être un number ou une string
type ID = number | string;
// Record pour créer des objets typés dynamiquement
// Résultat : { [clé: ID]: User }
type UserMap = Record<ID, User>;
// Exemple d'utilisation de Record :
const users: UserMap = {
1: { id: 1, name: "Alice", email: "alice@mail.com" },
"2": { id: 2, name: "Bob", email: "bob@mail.com" }
};
// Ici, la clé peut être un nombre ou une chaîne, et la valeur est toujours un User
// users[1] => { id: 1, name: "Alice", email: "alice@mail.com" }
// users["2"] => { id: 2, name: "Bob", email: "bob@mail.com" }
4. Utiliser les génériques
Les génériques permettent de rendre vos fonctions, types et interfaces réutilisables et typées de façon flexible. Ils sont très utilisés pour créer des helpers, des API ou des structures de données qui s'adaptent à différents types.
// Fonction générique
type ID = number | string;
function getById<T>(items: T[], id: ID, key: keyof T): T | undefined {
return items.find(item => item[key] === id);
}
// Type générique
interface ApiResponse<T> {
data: T;
error?: string;
}
// Utilisation
const users: ApiResponse<User[]> = {
data: [{ id: 1, name: "Alice" }],
};
// Générique avec contrainte
function first<T extends { id: ID }>(items: T[]): T | undefined {
return items[0];
}
5. Préférer unknown à any
any
désactive totalement la vérification de type : TypeScript ne vous aide plus à détecter les erreurs. unknown
est plus sûr : il oblige à vérifier le type avant d’utiliser la valeur.
function handleInput(input: unknown) {
if (typeof input === "string") {
// Ici, TypeScript sait que input est une string
console.log(input.toUpperCase());
}
}
function handleAny(input: any) {
// Pas de vérification, risque d'erreur
console.log(input.toUpperCase()); // Peut planter si input n'est pas une string
}
6. Typer explicitement le retour des fonctions
Il est recommandé de toujours spécifier le type de retour de vos fonctions, qu'elles soient asynchrones ou non. Même si TypeScript peut souvent l'inférer, le noter explicitement permet d'éviter les erreurs lors de modifications futures et améliore la lisibilité du code.
// Fonction asynchrone typée
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
// Fonction synchrone typée
function sum(a: number, b: number): number {
return a + b;
}
// Mauvais exemple :
function multiply(a: number, b: number) {
// Type de retour implicite : number
return a * b;
}
// Si la fonction change et retourne autre chose, TypeScript ne le signalera pas sans typage explicite,
// ce qui peut entrainer des erreurs dans le code appelant et des allé/retours
// inutiles lors de la maintenance.
7. Les types conditionnels
Les types conditionnels permettent d'introduire de la logique dans vos définitions de types. Ils servent à créer des types dynamiques, capables de s'adapter ou de transformer d'autres types selon des règles précises. Ils sont souvent utilisés en complément des génériques pour concevoir des helpers avancés et des transformations typées.
// Exemple 1 : Extraire le type de retour d'une fonction
// Utilisez unknown au lieu de any pour plus de sécurité
type ReturnTypeOf<T> = T extends (...args: unknown[]) => infer R ? R : never;
function sum(a: number, b: number): number {
return a + b;
}
type Result = ReturnTypeOf<typeof sum>; // number
// Exemple 2 : Filtrer les types
// Si T est un tableau, retourne le type des éléments, sinon never
type ElementType<T> = T extends Array<infer U> ? U : never;
type A = ElementType<string[]>; // string
type B = ElementType<number>; // never
// Exemple 3 : Retirer les propriétés optionnelles d'un objet
// Ce type utilitaire permet d'obtenir un type avec uniquement les propriétés obligatoires
type RequiredKeys<T> = {
[K in keyof T as {} extends Pick<T, K> ? never : K]: T[K]
};
type User = {
id: number;
name?: string;
email?: string;
};
type UserRequired = RequiredKeys<User>; // { id: number }
Conclusion
TypeScript offre une richesse de concepts et d’outils pour écrire du code plus sûr, lisible et maintenable. Ce guide n’est qu’une porte d’entrée : chaque projet, chaque équipe et chaque problématique peut amener à explorer des usages plus avancés ou à remettre en question certaines pratiques.