Le système de types de TypeScript va bien au-delà de l’annotation de variables avec string ou number. La vraie puissance se trouve dans les utility types, des generics intégrés qui transforment des types existants en nouveaux sans dupliquer les définitions. La plupart des codebases en utilisent deux ou trois et ignorent le reste. C’est une opportunité manquée, parce que le bon utility type peut remplacer des dizaines de lignes de définitions manuelles et attraper des bugs que les tests unitaires ne trouveraient jamais.
Partial et Required : basculer l’optionalité
Partial<T> rend chaque propriété d’un type optionnelle. C’est le choix naturel pour les fonctions de mise à jour où on n’envoie que les champs modifiés.
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
async function updateUser(id: string, fields: Partial<User>) {
return db.user.update({ where: { id }, data: fields });
}
// On n'envoie que ce qui a changé
await updateUser('123', { name: 'Thomas' });
Required<T> fait l’inverse. Il force chaque propriété optionnelle à être définie, ce qui est utile quand un objet de configuration a des valeurs par défaut mais doit être entièrement résolu avant utilisation.
interface AppConfig {
apiUrl?: string;
timeout?: number;
retries?: number;
}
function resolveConfig(partial: AppConfig): Required<AppConfig> {
return {
apiUrl: partial.apiUrl ?? 'https://api.example.com',
timeout: partial.timeout ?? 5000,
retries: partial.retries ?? 3,
};
}
Le type de retour garantit que chaque consommateur de la config résolue obtient des valeurs définies. Pas de optional chaining nécessaire en aval.
Pick et Omit : façonner des types à partir d’existants
Pick<T, K> extrait un sous-ensemble de propriétés. Au lieu de définir une nouvelle interface qui duplique la moitié d’une existante, on la dérive.
type UserCardProps = Pick<User, 'name' | 'email' | 'role'>;
function UserCard({ name, email, role }: UserCardProps) {
return (
<div>
<h3>{name}</h3>
<p>{email}</p>
<span>{role}</span>
</div>
);
}
Si User gagne un nouveau champ, UserCardProps reste intact. Si name change de type de string vers autre chose, UserCardProps se met à jour automatiquement. Le type reste synchronisé avec sa source sans aucun effort manuel.
Omit<T, K> fonctionne dans l’autre sens, gardant tout sauf les clés spécifiées. C’est idéal pour les props qui forwardent la majorité d’une interface mais gèrent certaines propriétés en interne.
type CreateUserInput = Omit<User, 'id'>;
function CreateUserForm({ onSubmit }: { onSubmit: (data: CreateUserInput) => void }) {
// id est généré côté serveur, le formulaire n'en a pas besoin
}
Record : les dictionnaires typés
Record<K, V> crée un type d’objet où chaque clé de type K correspond à une valeur de type V. Il remplace le pattern laxiste { [key: string]: any } par quelque chose que TypeScript peut réellement vérifier.
type Role = 'admin' | 'editor' | 'viewer';
interface Permissions {
canEdit: boolean;
canDelete: boolean;
canPublish: boolean;
}
const rolePermissions: Record<Role, Permissions> = {
admin: { canEdit: true, canDelete: true, canPublish: true },
editor: { canEdit: true, canDelete: false, canPublish: true },
viewer: { canEdit: false, canDelete: false, canPublish: false },
};
Si on ajoute un nouveau rôle à l’union Role, TypeScript signale immédiatement le Record comme incomplet. Chaque rôle doit être pris en compte. Ça transforme un “oups, on a oublié le nouveau rôle” en runtime en une erreur de compilation.
Extract et Exclude : filtrer les types union
Extract et Exclude opèrent sur les types union. Extract<T, U> ne garde que les membres de T assignables à U. Exclude<T, U> les supprime.
type AppEvent =
| { type: 'click'; x: number; y: number }
| { type: 'keypress'; key: string }
| { type: 'scroll'; offset: number }
| { type: 'resize'; width: number; height: number };
// Seulement les événements liés à la souris
type MouseEvent = Extract<AppEvent, { type: 'click' }>;
// Tout sauf le scroll
type NonScrollEvent = Exclude<AppEvent, { type: 'scroll' }>;
C’est particulièrement puissant dans les systèmes d’événements et les state machines où on doit restreindre une union large à un sous-ensemble spécifique sans réécrire les types.
ReturnType et Parameters : dériver depuis les fonctions
Quand le type de retour d’une fonction est complexe ou vient d’une librairie tierce, ReturnType l’extrait sans nécessiter une définition de type séparée.
function useAuth() {
return {
user: getCurrentUser(),
isAdmin: checkAdminRole(),
logout: () => clearSession(),
};
}
// Dérivé automatiquement, reste synchronisé si useAuth change
type AuthContext = ReturnType<typeof useAuth>;
Parameters fait la même chose pour les arguments de fonction. C’est utile quand on wrappe des fonctions ou qu’on construit du middleware qui doit correspondre à la signature d’une fonction.
function createUser(name: string, email: string, role: Role) {
// ...
}
type CreateUserParams = Parameters<typeof createUser>;
// [name: string, email: string, role: Role]
Generics custom : construire ses propres utility types
Les utility types intégrés sont eux-mêmes de simples types generics. En construire des custom suit le même pattern et permet d’encoder les contraintes du domaine dans le système de types.
type ApiResponse<T> = { status: 'success'; data: T } | { status: 'error'; error: string };
async function fetchUser(id: string): Promise<ApiResponse<User>> {
try {
const user = await db.user.findUnique({ where: { id } });
return { status: 'success', data: user };
} catch (e) {
return { status: 'error', error: 'User not found' };
}
}
// Rendre certaines clés optionnelles tout en gardant le reste requis
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
type CreateUserInput = PartialBy<User, 'id' | 'role'>;
// id et role sont optionnels, name et email sont requis
Les generics custom se composent avec ceux intégrés. PartialBy utilise Omit, Partial et Pick ensemble pour exprimer une contrainte qu’aucun d’entre eux ne peut exprimer seul. Une fois défini, c’est réutilisable dans toute la codebase.
Types conditionnels : des types qui branchent
Les types conditionnels permettent d’exprimer “si A, alors B, sinon C” au niveau des types. Ils alimentent la plupart des patterns avancés dans des librairies comme Zod, tRPC et TanStack Router.
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<number>; // false
Un exemple plus pratique : extraire le type résolu d’une promise, quelle que soit la profondeur d’imbrication.
type UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T;
type Result = UnwrapPromise<Promise<Promise<string>>>;
// string
Le mot-clé infer capture le type interne pendant le pattern matching. Combiné avec la récursion, il peut traverser n’importe quel nombre de types wrapper pour atteindre la valeur à l’intérieur.
Conclusion
Les utility types de TypeScript ne sont pas des abstractions académiques. Ce sont des outils pratiques qui gardent les définitions de types DRY, attrapent les erreurs de refactoring à la compilation, et rendent les états impossibles véritablement impossibles. Le meilleur code TypeScript n’a pas plus de types. Il en a moins, mais plus intelligents, qui dérivent d’une source de vérité unique et propagent les changements automatiquement.