SD
TypeScriptGenericsNext.jsNestJS

TypeScript Generics avancés : maîtrisez les types utilitaires pour un code robuste

Découvrez les generics TypeScript avancés : types conditionnels, mapped types et infer. Des exemples concrets pour Next.js et NestJS.

AMAlexis Mouchon8 min de lecture

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 :


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 :

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.