TypeScript Generics avancés : maîtrisez les types utilitaires pour un code robuste
Si vous utilisez TypeScript au quotidien, vous avez certainement déjà croisé les generics dans leur forme la plus simple : Array<T>, Promise<T> ou encore les composants React typés. Mais TypeScript offre un niveau de puissance bien supérieur avec les generics avancés : types conditionnels, mapped types, infer, et types récursifs. Ce sont ces outils qui permettent d'éliminer les any et de construire des APIs internes vraiment type-safe, que ce soit côté frontend avec Next.js ou côté backend avec NestJS.
Dans cet article, on va explorer les patterns les plus utiles en pratique, avec des exemples tirés directement d'une stack fullstack moderne.
Pourquoi aller au-delà des generics simples ?
Un generic simple comme function identity<T>(arg: T): T est utile, mais limité. Le vrai gain arrive quand on commence à contraindre, transformer ou inférer les types dynamiquement.
Voici les problèmes concrets que les generics avancés résolvent :
- Typer précisément les réponses d'une API GraphQL ou REST
- Créer des hooks React réutilisables sans perdre le type de retour
- Valider les données à l'entrée d'un controller NestJS avec des types dérivés automatiquement
- Écrire des utilitaires génériques (pick, omit, deep partial…) sans dépendre d'une librairie externe
Les types conditionnels : T extends U ? X : Y
Les types conditionnels permettent de brancher la logique de typage selon une condition. La syntaxe ressemble à un ternaire JavaScript, mais s'évalue à la compilation.
// Exemple : extraire le type contenu dans une Promise
type Awaited<T> = T extends Promise<infer U> ? U : T
// Utilisation
type ApiResponse = Promise<{ data: User[]; total: number }>
type ResolvedResponse = Awaited<ApiResponse>
// => { data: User[]; total: number }
Dans un contexte Next.js, c'est particulièrement utile pour typer les retours de Server Actions ou de fonctions fetch :
// Utilitaire pour extraire le type de retour d'un Server Action
type ServerActionResult<T extends (...args: any) => any> = Awaited<ReturnType<T>>
// Server Action
async function getUserById(id: string) {
return db.user.findUnique({ where: { id } })
}
// Type inféré automatiquement depuis l'action
type GetUserResult = ServerActionResult<typeof getUserById>
// => User | null
Plus besoin de déclarer manuellement le type de retour : il est déduit directement de l'implémentation.
infer : extraire un type au vol dans une contrainte
Le mot-clé infer est l'outil le plus puissant des generics avancés. Il permet d'extraire un type imbriqué depuis un type plus large, directement dans la contrainte d'un type conditionnel.
// Extraire le type des éléments d'un tableau
type ElementOf<T> = T extends Array<infer Item> ? Item : never
type UserArray = User[]
type SingleUser = ElementOf<UserArray> // => User
// Extraire le premier paramètre d'une fonction
type FirstArg<T extends (...args: any) => any> = T extends (first: infer F, ...rest: any[]) => any
? F
: never
// Exemple avec un guard NestJS
function createRoleGuard(role: string): CanActivate {
/* ... */
}
type RoleParam = FirstArg<typeof createRoleGuard> // => string
Dans NestJS, infer devient précieux pour typer des décorateurs personnalisés ou des factories de guards/interceptors sans répéter les types.
Mapped Types : transformer des types existants
Les mapped types permettent de créer un nouveau type en itérant sur les clés d'un type existant. TypeScript les utilise en interne pour Partial<T>, Required<T>, Readonly<T> ou Pick<T, K>.
// Recréer Partial depuis zéro pour comprendre la mécanique
type MyPartial<T> = {
[K in keyof T]?: T[K]
}
// Rendre toutes les propriétés en lecture seule de manière récursive
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]
}
// Utilisation avec une entité MongoDB
interface UserDocument {
_id: string
profile: {
firstName: string
lastName: string
address: {
city: string
zip: string
}
}
}
type ReadonlyUser = DeepReadonly<UserDocument>
// Toute la structure imbriquée est maintenant readonly
Un pattern très utile en pratique est de créer un type Nullable<T> pour représenter les champs qui peuvent être null dans une réponse GraphQL :
// Transformer toutes les propriétés pour accepter null
type Nullable<T> = {
[K in keyof T]: T[K] | null
}
// Ou seulement certaines clés
type NullableFields<T, K extends keyof T> = Omit<T, K> & {
[P in K]: T[P] | null
}
interface Post {
id: string
title: string
publishedAt: Date
deletedAt: Date // peut être null
}
type PostWithNullable = NullableFields<Post, 'deletedAt' | 'publishedAt'>
// publishedAt et deletedAt acceptent null, le reste reste obligatoire
Template Literal Types : typer les chaînes dynamiquement
Depuis TypeScript 4.1, les template literal types permettent de construire des types à partir de chaînes de caractères, comme JavaScript le fait avec les template literals.
// Générer des clés de traduction typées
type Lang = 'fr' | 'en' | 'es'
type Namespace = 'common' | 'auth' | 'dashboard'
type TranslationKey = `${Namespace}.${string}`
// => "common.xxx" | "auth.xxx" | "dashboard.xxx"
// Typer les événements d'un formulaire NestJS/React
type EventName<T extends string> = `on${Capitalize<T>}`
type ButtonEvents = EventName<'click' | 'focus' | 'blur'>
// => "onClick" | "onFocus" | "onBlur"
Ce pattern est particulièrement puissant pour typer des systèmes d'événements, des clés de cache Redis, ou des routes API de manière exhaustive sans les maintenir manuellement.
Pattern pratique : un hook useFetch entièrement typé
Voici un exemple complet qui combine plusieurs techniques vues plus haut pour créer un hook React réutilisable avec inférence automatique des types :
// hooks/useFetch.ts
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function useFetch<T>(
fetcher: () => Promise<T>,
deps: React.DependencyList = []
): FetchState<T> {
const [state, setState] = React.useState<FetchState<T>>({
status: "idle",
});
React.useEffect(() => {
setState({ status: "loading" });
fetcher()
.then((data) => setState({ status: "success", data }))
.catch((error) => setState({ status: "error", error }));
}, deps);
return state;
}
// Utilisation : T est inféré depuis le retour de `fetcher`
function UserProfile({ userId }: { userId: string }) {
const state = useFetch(() => fetchUser(userId), [userId]);
if (state.status === "loading") return <Spinner />;
if (state.status === "error") return <ErrorMessage error={state.error} />;
if (state.status === "success") {
// TypeScript sait que state.data est de type User ici
return <div>{state.data.name}</div>;
}
return null;
}
Le discriminated union sur status permet à TypeScript de réduire le type selon la branche — aucun cast, aucun as, une inférence parfaite.
En résumé
Les generics avancés TypeScript ne sont pas réservés aux auteurs de librairies. En pratique fullstack, ils permettent de :
- Éviter la duplication de types entre le backend et le frontend
- Inférer automatiquement les types de retour des Server Actions, queries GraphQL et controllers NestJS
- Créer des abstractions réutilisables (hooks, utilitaires) qui restent type-safe sans configuration supplémentaire
- Détecter les erreurs à la compilation plutôt qu'à l'exécution
Les patterns les plus utiles à retenir : types conditionnels pour brancher la logique, infer pour extraire des types imbriqués, mapped types pour transformer des structures, et template literal types pour typer des chaînes dynamiques.
Tu travailles sur un projet Next.js ou NestJS et tu veux aller plus loin sur le typage de ton architecture ? Contacte-moi — je serai ravi d'échanger sur les patterns adaptés à ton contexte.