Front-end

Génériques TypeScript : guide complet et exemples

Typescript Generiques Type-Parameters Extends-Constraints Keyof Conditional-Types Infer Mapped-Types Variance Repository-Pattern Utility-Types Zod
Génériques TypeScript : guide complet et exemples

Generiques TypeScript : syntaxe, contraintes extends, keyof, conditional types, infer, mapped types, variance et patterns Repository/Result/Zod.

Le problème du any — pourquoi les génériques

Sans génériques, écrire du code réutilisable en TypeScript force deux choix mauvais : utiliser any (perd tous les avantages du typage) ou dupliquer le code pour chaque type (violation du principe DRY). Les génériques résolvent exactement ce problème.

// PROBLÈME 1 : any perd toute information de type
function getFirstItem(arr: any[]): any {
    return arr[0];
}
const first = getFirstItem(['hello', 'world']);
first.toUpperCase(); // OK à la compilation, mais crash si first est undefined
first.nonExistentMethod(); // Aucune erreur TypeScript — dangerous!

// PROBLÈME 2 : duplication pour chaque type
function getFirstString(arr: string[]): string { return arr[0]; }
function getFirstNumber(arr: number[]): number { return arr[0]; }
// → 10 types = 10 fonctions identiques

// SOLUTION : générique — un seul code, pleinement typé
function getFirst<T>(arr: T[]): T | undefined {
    return arr[0];
}
const firstStr = getFirst(['hello', 'world']); // type: string | undefined
const firstNum = getFirst([1, 2, 3]);           // type: number | undefined
// TypeScript infère T automatiquement — pas besoin d'annoter manuellement
Règle de base : chaque fois que tu écris any pour rendre une fonction réutilisable, c'est un signal que tu devrais utiliser un générique à la place.

Syntaxe et inférence de type

Un paramètre générique se déclare entre chevrons <T> juste avant les parenthèses d'une fonction, ou après le nom d'une classe/interface. La convention est T pour Type, K pour Key, V pour Value, E pour Element — mais ce sont des conventions, pas des règles.

Inférence automatique vs annotation explicite

function wrap<T>(value: T): { value: T; timestamp: number } {
    return { value, timestamp: Date.now() };
}

// Inférence : TypeScript déduit T depuis l'argument
const wrappedStr = wrap('hello');    // T inféré comme string
const wrappedNum = wrap(42);         // T inféré comme number
const wrappedArr = wrap([1, 2, 3]);  // T inféré comme number[]

// Annotation explicite : utile quand l'inférence est incorrecte ou ambiguë
const wrappedLiteral = wrap<'active' | 'inactive'>('active');
// Sans annotation : T serait string (trop large)
// Avec annotation : T est 'active' | 'inactive' (précis)

Génériques dans les types alias et interfaces

// Type alias générique
type ApiResponse<T> = {
    data: T;
    statusCode: number;
    message: string;
    timestamp: string;
};

// Usage avec différents types de données
type UserResponse    = ApiResponse<User>;
type ProductResponse = ApiResponse<Product>;
type ListResponse<T> = ApiResponse<{ items: T[]; total: number; page: number }>;

// Interface générique avec contrainte de valeur par défaut (TypeScript 4.7+)
interface Repository<T, ID = number> {
    findById(id: ID): Promise<T | null>;
    findAll(): Promise<T[]>;
    save(entity: T): Promise<T>;
    delete(id: ID): Promise<void>;
}

Contraintes extends — restreindre T

Une contrainte générique T extends SomeType garantit que T possède au minimum les propriétés de SomeType. Sans contrainte, TypeScript ne peut assumer aucune propriété sur T.

Contrainte sur des propriétés spécifiques

// SANS contrainte : TypeScript ne connaît rien sur T
function getLength<T>(item: T): number {
    return item.length; // TypeScript ERROR: Property 'length' does not exist on type 'T'
}

// AVEC contrainte : T doit avoir une propriété length
function getLength<T extends { length: number }>(item: T): number {
    return item.length; // OK — TypeScript sait que T a length
}

getLength('hello');      // 5 — string a .length
getLength([1, 2, 3]);   // 3 — Array a .length
getLength({ length: 7 }); // 7 — objet ad-hoc avec length
getLength(42);           // TypeScript ERROR: number n'a pas .length

Contrainte extends sur une interface

interface HasId {
    id: number;
}

// T doit avoir un id — utile pour les opérations CRUD génériques
function updateById<T extends HasId>(collection: T[], updated: T): T[] {
    return collection.map(item => item.id === updated.id ? updated : item);
}

interface User extends HasId { name: string; email: string; }
interface Product extends HasId { title: string; price: number; }

const users: User[] = [{ id: 1, name: 'Alice', email: 'alice@example.com' }];
const updated = updateById(users, { id: 1, name: 'Alice Updated', email: 'alice@example.com' });
// Type exact préservé : T[] = User[]

Contraintes multiples avec intersection

interface Serializable {
    serialize(): string;
}

interface Validatable {
    validate(): boolean;
}

// T doit implémenter DEUX interfaces
function processEntity<T extends Serializable & Validatable & HasId>(entity: T): string {
    if (!entity.validate()) {
        throw new Error(`Entity ${entity.id} failed validation`);
    }
    return entity.serialize();
}

Multiples paramètres génériques

Une fonction peut avoir plusieurs paramètres génériques indépendants ou liés entre eux. L'ordre et les relations entre eux permettent d'exprimer des contraintes complexes.

// Deux paramètres indépendants
function zip<T, U>(arr1: T[], arr2: U[]): [T, U][] {
    const length = Math.min(arr1.length, arr2.length);
    return Array.from({ length }, (_, i) => [arr1[i], arr2[i]]);
}

const zipped = zip(['a', 'b', 'c'], [1, 2, 3]);
// Type: [string, number][]
// Valeur: [['a', 1], ['b', 2], ['c', 3]]
// Paramètre contraint par un autre : K doit être une clé de T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user = { id: 1, name: 'Bob', email: 'bob@example.com' };
const name = getProperty(user, 'name');   // type: string
const id   = getProperty(user, 'id');     // type: number
// getProperty(user, 'phone');            // TypeScript ERROR: 'phone' not in keyof User
// Paramètre avec valeur par défaut (TypeScript 4.7+)
type EventEmitter<Events extends Record<string, unknown> = Record<string, unknown>> = {
    on<K extends keyof Events>(event: K, handler: (data: Events[K]) => void): void;
    emit<K extends keyof Events>(event: K, data: Events[K]): void;
};

// Usage typé avec la map des événements
type AppEvents = {
    'user:login': { userId: number; timestamp: Date };
    'user:logout': { userId: number };
    'cart:update': { items: CartItem[] };
};

declare const emitter: EventEmitter<AppEvents>;
emitter.on('user:login', ({ userId, timestamp }) => { /* userId: number */ });
// emitter.emit('unknown:event', {}); ← TypeScript ERROR

Fonctions génériques utilitaires

Les fonctions génériques les plus utiles sont celles qui opèrent sur des structures de données sans en connaître le contenu. Voici des patterns récurrents en production.

Grouper par clé — groupBy générique

function groupBy<T, K extends string | number | symbol>(
    items: T[],
    keyFn: (item: T) => K
): Record<K, T[]> {
    return items.reduce((acc, item) => {
        const key = keyFn(item);
        (acc[key] ??= []).push(item);
        return acc;
    }, {} as Record<K, T[]>);
}

interface Order { id: number; status: 'pending' | 'shipped' | 'delivered'; total: number; }
const orders: Order[] = [
    { id: 1, status: 'pending', total: 50 },
    { id: 2, status: 'shipped', total: 120 },
    { id: 3, status: 'pending', total: 75 },
];

const byStatus = groupBy(orders, o => o.status);
// { pending: [Order, Order], shipped: [Order] }
// Type exact: Record<'pending'|'shipped'|'delivered', Order[]>

Fonction de transformation de clés

function mapValues<T extends object, V>(
    obj: T,
    transform: (value: T[keyof T], key: keyof T) => V
): Record<keyof T, V> {
    return Object.fromEntries(
        (Object.entries(obj) as [keyof T, T[keyof T]][]).map(
            ([key, value]) => [key, transform(value, key)]
        )
    ) as Record<keyof T, V>;
}

const prices = { apple: 1.5, banana: 0.75, cherry: 3.0 };
const withTax = mapValues(prices, price => price * 1.2);
// { apple: 1.8, banana: 0.9, cherry: 3.6 }
// Type: Record<'apple'|'banana'|'cherry', number>

Classes et interfaces génériques

Les classes génériques permettent de créer des structures de données réutilisables et complètement typées — pile, file, cache, repository.

Cache LRU générique

class LRUCache<K, V> {
    private cache = new Map<K, V>();

    constructor(private readonly maxSize: number) {}

    get(key: K): V | undefined {
        if (!this.cache.has(key)) return undefined;
        // Déplacer en fin de map (MRU position)
        const value = this.cache.get(key)!;
        this.cache.delete(key);
        this.cache.set(key, value);
        return value;
    }

    set(key: K, value: V): void {
        if (this.cache.has(key)) this.cache.delete(key);
        else if (this.cache.size >= this.maxSize) {
            // Supprimer le premier élément (LRU)
            this.cache.delete(this.cache.keys().next().value);
        }
        this.cache.set(key, value);
    }

    has(key: K): boolean { return this.cache.has(key); }
}

// Usage avec types spécifiques
const userCache = new LRUCache<number, User>(100);
userCache.set(1, { id: 1, name: 'Alice', email: 'alice@example.com' });
const user = userCache.get(1); // type: User | undefined

Repository générique avec Angular

import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

abstract class BaseRepository<T extends HasId, CreateDto = Omit<T, 'id'>> {
    constructor(
        protected http: HttpClient,
        protected endpoint: string
    ) {}

    findAll(): Observable<T[]> {
        return this.http.get<T[]>(this.endpoint);
    }

    findById(id: number): Observable<T> {
        return this.http.get<T>(`${this.endpoint}/${id}`);
    }

    create(dto: CreateDto): Observable<T> {
        return this.http.post<T>(this.endpoint, dto);
    }

    update(id: number, dto: Partial<CreateDto>): Observable<T> {
        return this.http.patch<T>(`${this.endpoint}/${id}`, dto);
    }

    delete(id: number): Observable<void> {
        return this.http.delete<void>(`${this.endpoint}/${id}`);
    }
}

// Chaque repository hérite du comportement CRUD sans duplication
@Injectable({ providedIn: 'root' })
class UserRepository extends BaseRepository<User> {
    constructor(http: HttpClient) { super(http, '/api/users'); }
}

@Injectable({ providedIn: 'root' })
class ProductRepository extends BaseRepository<Product, CreateProductDto> {
    constructor(http: HttpClient) { super(http, '/api/products'); }
}

Génériques avec keyof et typeof

Combiner les génériques avec keyof et typeof permet d'écrire des fonctions qui opèrent sur des propriétés d'objets de façon complètement type-safe.

// Accès type-safe à des propriétés imbriquées
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
    return items.map(item => item[key]);
}

const products: Product[] = [
    { id: 1, title: 'Laptop', price: 999 },
    { id: 2, title: 'Mouse', price: 29 },
];

const titles = pluck(products, 'title');  // string[]
const prices = pluck(products, 'price');  // number[]
// pluck(products, 'unknown');            // TypeScript ERROR
// Créer un type depuis la valeur d'un objet const (typeof)
const ROUTES = {
    home:     '/',
    users:    '/users',
    products: '/products',
    settings: '/settings',
} as const;

type AppRoute = typeof ROUTES[keyof typeof ROUTES];
// type: '/' | '/users' | '/products' | '/settings'

function navigate(route: AppRoute): void {
    window.location.href = route;
}

navigate(ROUTES.users);    // OK
navigate('/users');        // OK — même valeur littérale
// navigate('/dashboard'); // TypeScript ERROR — pas dans ROUTES

Conditional types et infer

Les conditional types permettent de créer des types qui varient selon une condition. T extends U ? X : Y — si T est assignable à U, le type est X, sinon Y. Le mot-clé infer extrait un sous-type depuis la condition.

// Extraire le type des éléments d'un tableau
type ElementType<T> = T extends (infer E)[] ? E : never;

type StringArray = string[];
type Element = ElementType<StringArray>; // string

type MixedArray = (number | boolean)[];
type MixedElement = ElementType<MixedArray>; // number | boolean
// Déballer une Promise (Awaited fait ça en natif depuis TS 4.5)
type Unwrap<T> = T extends Promise<infer R> ? R : T;

type A = Unwrap<Promise<string>>;  // string
type B = Unwrap<string>;            // string (déjà débaillé)

// Equivalent natif TypeScript 4.5+
type C = Awaited<Promise<string>>; // string
// IsArray — conditional type utilitaire
type IsArray<T> = T extends any[] ? true : false;

type Test1 = IsArray<string[]>; // true
type Test2 = IsArray<string>;   // false

// Fonction qui se comporte différemment selon le type
function flatten<T>(value: T): T extends any[] ? T[number] : T {
    if (Array.isArray(value)) return value[0];
    return value as any;
}
Conditional types distribués : quand T est une union, le conditional type s'applique à chaque membre séparément. string | number extends any[] ? true : false donne false | false = false, pas une erreur.

Patterns en production

Hook React / composable Angular générique

// Angular Signal générique pour les états asynchrones
import { signal, computed, Signal } from '@angular/core';

interface AsyncState<T> {
    data: T | null;
    loading: boolean;
    error: string | null;
}

function createAsyncSignal<T>(initialData: T | null = null) {
    const state = signal<AsyncState<T>>({
        data: initialData,
        loading: false,
        error: null,
    });

    return {
        state: state.asReadonly(),
        data:    computed(() => state().data),
        loading: computed(() => state().loading),
        error:   computed(() => state().error),

        async execute(asyncFn: () => Promise<T>): Promise<void> {
            state.set({ data: null, loading: true, error: null });
            try {
                const data = await asyncFn();
                state.set({ data, loading: false, error: null });
            } catch (e) {
                state.set({ data: null, loading: false, error: String(e) });
            }
        },
    };
}

// Usage dans un composant Angular
const usersAsync = createAsyncSignal<User[]>([]);
await usersAsync.execute(() => fetch('/api/users').then(r => r.json()));
Anti-pattern à éviter : les génériques sur-ingéniérés. Si une fonction générique avec 4 paramètres <T, K, V, R> est difficile à lire, c'est probablement le signe qu'il faut la découper en fonctions plus petites et spécifiques.

Mapped types génériques — transformer un type

Un mapped type itère sur les clés d'un type pour produire un nouveau type. Combiné aux génériques, il permet d'écrire les utility types (Partial, Readonly, Required) ou d'en créer des dérivés métier. La syntaxe est { [K in keyof T]: ... }K parcourt les clés et la valeur droite construit le nouveau type pour chaque clé.

Les modificateurs +?, -?, +readonly, -readonly permettent d'ajouter ou retirer ces traits. type Required<T> = { [K in keyof T]-?: T[K] } retire l'optionnalité. type Mutable<T> = { -readonly [K in keyof T]: T[K] } retire readonly. Ces deux utilitaires ne sont pas dans la lib standard mais sont fréquents en code applicatif.

// Réimplémentation de Partial<T> — montre comment ça marche en interne
type MyPartial<T> = { [K in keyof T]?: T[K] };

// Nullable — rend chaque propriété nullable
type Nullable<T> = { [K in keyof T]: T[K] | null };

// DeepReadonly — récursif sur les objets imbriqués
type DeepReadonly<T> = {
    readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

interface User { id: number; profile: { name: string; age: number } }
type FrozenUser = DeepReadonly<User>;
// profile.name est readonly aussi — récursion automatique

// PickByType — sélectionne uniquement les propriétés d'un type donné
type PickByType<T, V> = {
    [K in keyof T as T[K] extends V ? K : never]: T[K];
};

interface Form { name: string; age: number; email: string; active: boolean }
type StringFields = PickByType<Form, string>; // { name: string; email: string }

Le modificateur as (TypeScript 4.1+) filtre ou renomme les clés au moment du mapping. Combiné aux conditional types, c'est ce qui rend possible des libs comme Zod ou Prisma qui dérivent automatiquement des types à partir d'un schéma.

Remapping pour générer des noms de getters

// Génère un type avec getName(), getAge(), getEmail() pour chaque propriété
type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }

Variance — covariance et contravariance

La variance détermine la compatibilité entre types génériques : si Dog est un sous-type de Animal, est-ce que Array<Dog> est un sous-type de Array<Animal> ? La réponse change selon la position du paramètre dans le type.

class Animal { name = ''; }
class Dog extends Animal { breed = ''; }

// COVARIANCE — "produit" T : sous-type → sous-type
type Producer<T> = { get(): T };

const dogProducer: Producer<Dog> = { get: () => new Dog() };
const animalProducer: Producer<Animal> = dogProducer; // OK
// Un producteur de Dog est aussi un producteur d'Animal

// CONTRAVARIANCE — "consomme" T : sous-type → super-type
type Consumer<T> = (item: T) => void;

const animalConsumer: Consumer<Animal> = (a) => console.log(a.name);
const dogConsumer: Consumer<Dog> = animalConsumer; // OK
// Un consommateur d'Animal peut consommer un Dog (qui EST un Animal)

Mot-clé in et out (TypeScript 4.7+)

TypeScript permet désormais d'annoter explicitement la variance pour aider le compilateur :

// in = contravariant (paramètre d'entrée uniquement)
type EventHandler<in T> = (event: T) => void;

// out = covariant (utilisé en sortie uniquement)
type State<out T> = { current: T };

// Sans annotation, TypeScript infère via "bivariance" — moins strict mais plus pratique

Cette précision améliore les erreurs : assigner State<Dog> à State<Animal> est valide (covariance), mais assigner State<string> à State<number> produit une erreur compréhensible plutôt qu'un message générique sur les types incompatibles.

Bivariance par défaut sur les paramètres de méthode

Par compatibilité historique avec JavaScript, TypeScript traite les paramètres de méthodes (pas les fonctions) en bivariance — moins strict que la contravariance pure. Cela explique pourquoi EventListener<MouseEvent> est assignable à EventListener<Event> dans les deux sens. Pour forcer la stricte contravariance, activez strictFunctionTypes: true dans tsconfig.json (inclus dans strict: true). Le mode strict est obligatoire sur tout nouveau projet TypeScript en 2026.

Higher-Kinded Types — la limite actuelle

Un Higher-Kinded Type (HKT) est un générique dont le paramètre est lui-même générique : F<T>F peut être Array, Promise, Observable, etc. TypeScript ne supporte pas nativement les HKT, contrairement à Haskell ou Scala. C'est la principale limite du système de types pour des libs comme fp-ts.

// Ce qu'on aimerait écrire (PAS valide TypeScript)
interface Functor<F<_>> {
    map<A, B>(fa: F<A>, f: (a: A) => B): F<B>;
}

// Contournement par "defunctionalization" (utilisé par fp-ts, Effect.ts)
interface HKT<URI, A> { _URI: URI; _A: A; }

interface URItoKind<A> {
    Array:   Array<A>;
    Option:  { _tag: 'Some' | 'None'; value?: A };
    Promise: Promise<A>;
}

type Kind<F extends keyof URItoKind<unknown>, A> = URItoKind<A>[F];

// Permet d'écrire un map générique sur n'importe quel F
function map<F extends keyof URItoKind<unknown>, A, B>(
    fa: Kind<F, A>,
    f: (a: A) => B,
): Kind<F, B> { /* dispatch par URI */ throw 0; }

En pratique, ce pattern est réservé aux libs fonctionnelles avancées. Pour 99% du code applicatif, vous n'avez pas besoin de HKT — les génériques classiques suffisent. La proposition d'ajouter les HKT à TypeScript existe depuis 2014 (issue #1213) mais reste non priorisée par l'équipe Microsoft.

5 pièges fréquents avec les génériques

1. Oublier l'inférence — sur-spécifier le type

// ❌ Inutile — TypeScript infère déjà T = string
const result = getFirst<string>(['a', 'b']);

// ✅ Laisser l'inférence
const result = getFirst(['a', 'b']);

2. Trop de paramètres T sans contraintes

// ❌ T, U, V, R — illisible et peu utile
function transform<T, U, V, R>(a: T, b: U, c: V, fn: (a:T,b:U,c:V) => R): R { /*...*/ }

// ✅ Découper en fonctions plus petites ou utiliser des types nommés
interface TransformContext { /* ... */ }
function transform<R>(ctx: TransformContext, fn: (c: TransformContext) => R): R { /*...*/ }

3. Confusion entre extends contrainte et conditional

// extends dans <T extends ...> = CONTRAINTE
function logId<T extends { id: number }>(obj: T): void { console.log(obj.id); }

// extends dans T extends X ? Y : Z = CONDITIONAL TYPE
type IsArray<T> = T extends unknown[] ? true : false;

4. Génériques inférés via valeur par défaut

// Valeur par défaut = T inféré comme {} si non précisé
function createState<T = unknown>(initial: T): { value: T } {
    return { value: initial };
}
const s1 = createState(42);        // T = number
const s2 = createState<string>(); // T = string, initial obligatoire... ❌ erreur car initial requis
const s3 = createState();           // T = unknown

5. any remplacé par unknown

Beaucoup de code legacy utilise function parse(data: any). Le remplacement direct par function parse<T>(data: T) ne typifie pas pour autant — l'appelant doit fournir T. Préférez function parse(data: unknown) + type guards à l'intérieur, ou un schéma Zod qui infère T.

Génériques dans l'écosystème — Angular, React, Zod

Angular

HttpClient.get<T>(), Signal<T>, WritableSignal<T>, FormControl<T>, BehaviorSubject<T>, Observable<T> — toutes les APIs réactives sont génériques. Une HttpClient.get<User[]>('/api/users') retourne Observable<User[]> sans annotation supplémentaire.

React + TypeScript

useState<T>(), useRef<T>(), useContext<T>(), useReducer<State, Action>() — les hooks utilisent les génériques pour typer leur retour. Les composants génériques s'écrivent function List<T>({ items, render }: ListProps<T>).

Zod — inférence depuis un schéma

import { z } from 'zod';

const UserSchema = z.object({
    id: z.string().uuid(),
    name: z.string().min(2),
    age: z.number().int().positive(),
});

// Inférence automatique du type TypeScript depuis le schéma
type User = z.infer<typeof UserSchema>;
// { id: string; name: string; age: number }

const result = UserSchema.safeParse(unknownData);
if (result.success) {
    const user: User = result.data; // typé automatiquement
}

z.infer<typeof schema> exploite les conditional types + infer pour extraire le type TypeScript du schéma de validation. Un seul source of truth, runtime + compile-time alignés.

Génériques avec valeurs par défaut et héritage

Les valeurs par défaut sur les paramètres de type rendent les génériques plus ergonomiques pour les consommateurs : on peut les omettre dans les cas courants.

// Valeur par défaut pour T
type ApiResponse<T = unknown> = { data: T; status: number };

const r1: ApiResponse = { data: {}, status: 200 };           // T = unknown
const r2: ApiResponse<User> = { data: user, status: 200 };   // T = User

// Plusieurs paramètres avec défaut — l'ordre compte
type Cache<K extends string = string, V = unknown> = Map<K, V>;
const cache: Cache<'user' | 'order', object> = new Map();

tRPC — typage end-to-end client/serveur

tRPC pousse l'inférence générique encore plus loin : le type du serveur est importé directement par le client, qui obtient l'autocomplétion sur tous les endpoints sans génération de code intermédiaire. Le type AppRouter est paramétré sur chaque procédure, et le client consomme via const trpc = createTRPCClient<AppRouter>(). Si une route serveur retourne User[], le client le sait — modifier le contrat backend casse immédiatement le frontend à la compilation. C'est le pattern « one TypeScript codebase » qui élimine les API contracts dérivés (OpenAPI, Swagger).

Drizzle ORM — types inférés depuis le schéma SQL

Drizzle déclare les tables PostgreSQL/MySQL en TypeScript : const users = pgTable('users', { id: serial('id'), name: text('name').notNull() }). Le type des lignes est inféré via InferSelectModel<typeof users>, et les queries renvoient des types précis selon les colonnes sélectionnées. db.select({ name: users.name }).from(users) retourne { name: string }[] — pas User[]. Cette précision élimine les bugs N+1 et les payloads inutilement gros.

Mini-projet appliqué — Repository CRUD multi-entités

Pour ancrer tous les patterns vus dans un cas concret, voici un mini-projet réel : un système CRUD générique qui gère plusieurs entités (User, Product, Order) avec un seul Repository typé, des DTO inférés, et une validation Zod au runtime. C'est le code qu'on retrouve dans 80 % des back-offices SaaS — la version simplifiée mais fonctionnelle.

1. Modèle de domaine et contrainte de base

// Contrainte commune : toute entité doit avoir un identifiant
interface Entity {
    id: string;
    createdAt: Date;
    updatedAt: Date;
}

interface User extends Entity {
    email: string;
    role: 'admin' | 'member' | 'guest';
}

interface Product extends Entity {
    sku: string;
    price: number;
    stock: number;
}

interface Order extends Entity {
    userId: string;
    productIds: string[];
    status: 'draft' | 'paid' | 'shipped';
}

2. DTO inférés via utility types — pas de duplication

Plutôt que d'écrire à la main CreateUserDto, UpdateUserDto pour chaque entité, on dérive les types depuis le modèle. Pour comprendre Omit et Partial en profondeur, voir le guide des utility types.

// CreateDto : on retire ce que le serveur génère
type CreateDto<T extends Entity> = Omit<T, 'id' | 'createdAt' | 'updatedAt'>;

// UpdateDto : tout est optionnel sauf l'id
type UpdateDto<T extends Entity> = Partial<CreateDto<T>> & { id: string };

// Usage typé automatiquement
type CreateUserDto    = CreateDto<User>;    // { email: string; role: 'admin' | 'member' | 'guest' }
type UpdateProductDto = UpdateDto<Product>; // { id: string; sku?: string; price?: number; stock?: number }
type CreateOrderDto   = CreateDto<Order>;   // { userId: string; productIds: string[]; status: 'draft' | 'paid' | 'shipped' }

3. Repository générique avec contraintes

abstract class Repository<T extends Entity> {
    constructor(protected readonly resource: string) {}

    async findAll(): Promise<T[]> {
        const res = await fetch(`/api/${this.resource}`);
        if (!res.ok) throw new Error(`GET /${this.resource} failed: ${res.status}`);
        return res.json() as Promise<T[]>;
    }

    async findById(id: string): Promise<T | null> {
        const res = await fetch(`/api/${this.resource}/${id}`);
        if (res.status === 404) return null;
        if (!res.ok) throw new Error(`GET /${this.resource}/${id} failed: ${res.status}`);
        return res.json() as Promise<T>;
    }

    async create(dto: CreateDto<T>): Promise<T> {
        const res = await fetch(`/api/${this.resource}`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(dto),
        });
        if (!res.ok) throw new Error(`POST /${this.resource} failed: ${res.status}`);
        return res.json() as Promise<T>;
    }

    async update(dto: UpdateDto<T>): Promise<T> {
        const res = await fetch(`/api/${this.resource}/${dto.id}`, {
            method: 'PATCH',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(dto),
        });
        if (!res.ok) throw new Error(`PATCH /${this.resource}/${dto.id} failed: ${res.status}`);
        return res.json() as Promise<T>;
    }
}

// Implémentations concrètes — 3 lignes chacune
class UserRepository    extends Repository<User>    { constructor() { super('users');    } }
class ProductRepository extends Repository<Product> { constructor() { super('products'); } }
class OrderRepository   extends Repository<Order>   { constructor() { super('orders');   } }
Gain mesuré : sur un back-office avec 12 entités, ce pattern réduit le code repository de ~1800 lignes à ~240 lignes (−86 %). Plus important : ajouter une 13e entité ne nécessite que 3 lignes de code.

4. Validation runtime avec Zod — un seul schéma, deux usages

Le typage compile-time ne suffit pas quand les données viennent du réseau. Pour les patterns complets de validation runtime, voir notre guide sur les type guards et le narrowing.

import { z } from 'zod';

const UserSchema = z.object({
    id: z.string().uuid(),
    email: z.string().email(),
    role: z.enum(['admin', 'member', 'guest']),
    createdAt: z.coerce.date(),
    updatedAt: z.coerce.date(),
});

// Le type User est dérivé du schéma — un seul source of truth
type User = z.infer<typeof UserSchema>;

// Repository renforcé : valide à la réception
class SafeUserRepository extends Repository<User> {
    constructor() { super('users'); }

    async findById(id: string): Promise<User | null> {
        const raw = await super.findById(id);
        if (raw === null) return null;
        // Validation runtime — throw si la donnée serveur ne respecte pas le contrat
        return UserSchema.parse(raw);
    }
}

5. Service métier avec injection Angular

L'usage en Angular 17+ avec inject() et les Signals. Si vous découvrez les Signals, commencez par notre guide complet sur les Angular Signals.

import { Injectable, inject, signal, computed } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class UserService {
    private repo = new SafeUserRepository();

    // État réactif typé
    private usersSignal = signal<User[]>([]);
    readonly users = this.usersSignal.asReadonly();

    // Computed dérivé — le type est inféré
    readonly admins = computed(() => this.users().filter(u => u.role === 'admin'));

    async load(): Promise<void> {
        const list = await this.repo.findAll();
        this.usersSignal.set(list);
    }

    async createUser(dto: CreateUserDto): Promise<void> {
        const created = await this.repo.create(dto);
        this.usersSignal.update(list => [...list, created]);
    }
}

Ce que ce mini-projet démontre

  • Une seule contrainte (T extends Entity) propage à toute la hiérarchie — DTO, Repository, Service.
  • Les utility types (Omit, Partial) évitent 12+ types DTO écrits à la main par entité.
  • Zod + z.infer fusionne validation runtime et typage compile-time — un seul source of truth.
  • Les méthodes héritées retournent Promise<T> exact (pas Promise<any>) — autocomplétion IDE complète sur chaque appel.

Pour aller plus loin sur les patterns de typage avancés, lire également quand choisir type ou interface et comment les decorators étendent ce pattern en NestJS pour l'injection de dépendances et la validation déclarative.

Questions fréquentes

Les génériques TypeScript () permettent de créer des fonctions, classes et interfaces qui fonctionnent avec plusieurs types tout en conservant la sûreté du typage. Sans génériques : function identity(arg: any): any perd toute information de type. Avec génériques : function identity(arg: T): T — le type de retour correspond toujours au type d'entrée. Avantages : réutilisabilité du code, sûreté du typage, autocomplétion IDE, et détection d'erreurs à la compilation plutôt qu'à l'exécution.

Les contraintes () limitent les types acceptés par un générique. Exemple : function getProperty(obj: T, key: K): T[K] — K doit être une clé existante de T. Autre exemple : function merge(a: T, b: U): T & U. Utilisez extends avec une interface pour contraindre à des objets ayant certaines propriétés : garantit que T possède ces champs.

Interface générique : interface Repository { findById(id: number): Promise; save(entity: T): Promise; }. Classe générique : class Stack { private items: T[] = []; push(item: T): void { this.items.push(item); } pop(): T | undefined { return this.items.pop(); } }. En Angular, les génériques sont omniprésents : Observable, HttpClient.get(), Signal, etc. Comprendre les génériques est essentiel pour maîtriser les types retournés par les APIs Angular.

any désactive complètement la vérification de type — le compilateur ne contrôle plus rien, ce qui annule tous les avantages de TypeScript. Les génériques préservent la sûreté du typage : si vous passez une string, TypeScript sait que le retour est une string. Exemple : avec any[], tableau.push("texte") et tableau[0].toFixed() compilent sans erreur (crash à l'exécution). Avec T[], les opérations invalides sont détectées à la compilation. Évitez any — utilisez unknown (plus sûr) ou des génériques.

keyof T produit le type union des clés de T. Combiné aux génériques, il permet d'écrire des accesseurs type-safe : function get&lt;T, K extends keyof T&gt;(obj: T, key: K): T[K] retourne le type exact de la propriété demandée. Si T = { name: string; age: number }, alors K est "name" | "age" et T[K] est string | number selon la clé. C'est ce qui rend possibles les libs comme Lodash typées (get/set/pick) ou les ORMs comme Prisma qui valident les colonnes au moment du build.

Trois cas principaux. (1) Types utilitaires : NonNullable&lt;T&gt; = T extends null | undefined ? never : T retire null/undefined. (2) Inférence avec infer : ReturnType&lt;T&gt; = T extends (...a: any) => infer R ? R : never extrait le type de retour d'une fonction. (3) Dispatch sur type : type ApiResponse&lt;T&gt; = T extends Error ? { error: T } : { data: T }. Les conditional types se distribuent automatiquement sur les unions : si T = A | B, alors T extends X ? Y : Z devient (A extends X ? Y : Z) | (B extends X ? Y : Z).

infer déclare une variable de type à l'intérieur d'un conditional type. type Awaited&lt;T&gt; = T extends Promise&lt;infer U&gt; ? U : T extrait la valeur résolue d'une Promise. type FirstArg&lt;F&gt; = F extends (a: infer A, ...args: any) => any ? A : never récupère le type du premier paramètre. infer est l'outil qui rend possibles les types les plus puissants de TypeScript : ReturnType, Parameters, ConstructorParameters, Awaited — tous écrits avec infer dans lib.es5.d.ts.

interface Repository&lt;T extends { id: string }&gt; { findById(id: string): Promise&lt;T | null&gt;; save(entity: T): Promise&lt;T&gt;; delete(id: string): Promise&lt;void&gt;; findAll(): Promise&lt;T[]&gt;; }. La contrainte T extends { id: string } garantit que chaque entité a un id. Implémentation : class UserRepository implements Repository&lt;User&gt; { ... }. Pattern courant en NestJS, Angular et Node.js — un seul code repository qui gère toutes les entités, type-safe, avec autocomplétion IDE complète sur les méthodes.

Aucun coût runtime. Les génériques TypeScript sont une construction purement compile-time — ils disparaissent totalement après la compilation. Le code JavaScript généré est strictement identique avec ou sans génériques. C'est la même règle que pour les types, interfaces et utility types : effacés (type erasure) avant l'exécution. Conséquence pratique : on ne peut pas écrire `if (x instanceof T)` ni `new T()` dans un générique — T n'existe plus à runtime. Pour vérifier un type à runtime, il faut Zod, Valibot, ou un type guard custom.

Génériques quand le type d'entrée détermine le type de sortie (relation paramétrique) : function identity&lt;T&gt;(x: T): T garantit que retour = entrée. Une union perdrait cette relation : function identity(x: A | B | C): A | B | C — un appelant passant A recevrait A | B | C, pas A. Union quand l'ensemble est fermé et connu (status: "idle" | "loading" | "error"). Générique quand l'ensemble est ouvert (futur appelant inconnu). Règle simple : si vous écrivez T puis le réutilisez plus loin dans la signature, c'est un générique ; si vous listez exhaustivement des cas, c'est une union.

Trois techniques. (1) Hover IDE : VS Code/WebStorm affichent le type résolu au survol — utile pour les types intermédiaires. (2) Type alias intermédiaire : `type Debug = MyComplexType&lt;User&gt;` puis hover sur Debug — TypeScript expose la structure résolue. (3) Astuce expert : `type Expand&lt;T&gt; = T extends infer O ? { [K in keyof O]: O[K] } : never` force la résolution récursive — `type Final = Expand&lt;Partial&lt;Pick&lt;User, "name" | "email"&gt;&gt;&gt;` affiche `{ name?: string; email?: string }` au lieu de `Partial&lt;Pick&lt;...&gt;&gt;`. Pour les erreurs d'inférence, ajoutez `// @ts-expect-error` puis lisez le message exact.

Partager