Front-end angularforall.com

- 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.

Partager