Front-end angularforall.com

- Utility types TypeScript : Pick, Omit, Record, Partial

Typescript Utility-Types Pick Omit Record Partial Required Awaited Parameters Returntype Branded-Types Dto-Typage
Utility types TypeScript : Pick, Omit, Record, Partial

Utility types TypeScript : Pick, Omit, Record, Partial, Required, Awaited, Parameters, ReturnType, branded types et patterns DTO d API typees.

Pourquoi les Utility Types existent

Dans une codebase TypeScript sans Utility Types, on duplique les interfaces. Une interface User complète devient une UserPublic recopiée manuellement, une UserUpdate avec des propriétés optionnelles, une UserPreview avec 3 champs seulement. Chaque refactor du type source oblige à mettre à jour 4 définitions en parallèle.

Les Utility Types résolvent ce problème en transformant un type existant à la compilation, sans duplication. Ils sont intégrés dans lib.es5.d.ts de TypeScript — aucune installation, aucun import nécessaire.

Principe clé : un Utility Type ne crée pas de nouvelle structure en mémoire. Il opère uniquement au niveau du système de types, à la compilation. Le JavaScript émis est identique.

Vue d'ensemble des Utility Types built-in TypeScript 5.x

Utility TypeCe qu'il faitCas d'usage typique
Pick<T, K>Garde seulement les clés K de TDTO, vue partielle
Omit<T, K>Supprime les clés K de TExclure champs sensibles
Partial<T>Rend toutes les propriétés optionnellesPATCH d'API, formulaires
Required<T>Rend toutes les propriétés obligatoiresValidation finale, contraindre les optionnels
Readonly<T>Rend toutes les propriétés en lecture seuleState immutable, config
Record<K, V>Mappe des clés K vers un type VDictionnaires, lookup tables
ReturnType<F>Infère le type de retour d'une fonctionWrapper, mock, adapter
Parameters<F>Infère les paramètres d'une fonctionHOF, decorators
NonNullable<T>Supprime null et undefined de TAprès guards nullabilité
Exclude<T, U>Supprime U des membres de T (union)Affiner des unions
Extract<T, U>Garde les membres de T assignables à UFiltrer des unions

Pick<T, K> — sélectionner des propriétés précises

Pick<T, K> crée un nouveau type en conservant uniquement les clés K du type T. K doit être une union littérale de clés existantes dans T — TypeScript émet une erreur si une clé est introuvable.

Syntaxe et implémentation interne

Voici comment TypeScript implémente Pick dans sa lib standard :

// Implémentation interne de Pick dans TypeScript
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};
// K extends keyof T garantit que les clés demandées existent bien dans T

Exemple concret : interface User vers plusieurs DTOs

interface User {
    id: number;
    firstName: string;
    lastName: string;
    email: string;
    passwordHash: string;
    role: 'admin' | 'user' | 'moderator';
    createdAt: Date;
    lastLogin: Date;
}

// DTO pour la liste des utilisateurs (côté front)
type UserListItem = Pick<User, 'id' | 'firstName' | 'lastName' | 'role'>;
// { id: number; firstName: string; lastName: string; role: 'admin'|'user'|'moderator' }

// DTO pour le profil public (sans données sensibles)
type UserPublicProfile = Pick<User, 'id' | 'firstName' | 'lastName' | 'createdAt'>;

// Payload pour un formulaire de création de compte
type UserRegistrationForm = Pick<User, 'firstName' | 'lastName' | 'email'> & {
    password: string;  // champ temporaire jamais stocké tel quel
    confirmPassword: string;
};

Pick dans les appels API avec Angular HttpClient

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

type UserListItem = Pick<User, 'id' | 'firstName' | 'lastName' | 'role'>;

@Injectable({ providedIn: 'root' })
export class UserService {
    constructor(private http: HttpClient) {}

    // L'API retourne exactement ce sous-ensemble — Pick documente le contrat
    getUsers(): Observable<UserListItem[]> {
        return this.http.get<UserListItem[]>('/api/users');
    }
}
Différence Pick vs interface avec propriétés optionnelles : Pick hérite exactement de la contrainte (obligatoire/optionnel) du type source. Si email est optionnel dans User, il reste optionnel dans Pick<User, 'email'>.

Omit<T, K> — exclure des propriétés sensibles

Omit<T, K> est l'inverse de Pick : il produit un type avec toutes les propriétés de T sauf celles listées dans K. C'est particulièrement utile pour masquer des champs sensibles ou supprimer des champs gérés automatiquement.

Implémentation interne et différence avec Pick

// Implémentation interne de Omit
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
// Omit est défini EN TERMES de Pick + Exclude — il n'est pas primitif
Règle pratique : si tu veux garder 3 propriétés sur 15, utilise Pick. Si tu veux enlever 2 propriétés sur 15, utilise Omit. Choisir le mauvais inverse ton intention et rend le code difficile à lire.

Cas d'usage : typer les mutations de base de données

interface Article {
    id: number;           // généré par la BDD
    slug: string;         // généré automatiquement
    title: string;
    content: string;
    authorId: number;
    publishedAt: Date | null;
    createdAt: Date;      // géré par la BDD
    updatedAt: Date;      // géré par la BDD
}

// Pour créer un article, on ne fournit jamais id, slug, createdAt, updatedAt
type CreateArticleDto = Omit<Article, 'id' | 'slug' | 'createdAt' | 'updatedAt'>;
// { title: string; content: string; authorId: number; publishedAt: Date | null }

// Pour la mise à jour (PATCH), on veut les champs modifiables en optionnel
type UpdateArticleDto = Partial<Omit<Article, 'id' | 'slug' | 'createdAt' | 'updatedAt'>>;
// { title?: string; content?: string; authorId?: number; publishedAt?: Date | null }

Omit pour sécuriser les réponses API

interface UserWithSecrets {
    id: number;
    email: string;
    passwordHash: string;
    totpSecret: string | null;
    sessionTokens: string[];
    name: string;
}

// Ce type est celui qu'on envoie au client — jamais les secrets
type SafeUser = Omit<UserWithSecrets, 'passwordHash' | 'totpSecret' | 'sessionTokens'>;
// { id: number; email: string; name: string }

// Fonction typée qui garantit l'absence des champs sensibles
function toSafeUser(user: UserWithSecrets): SafeUser {
    const { passwordHash, totpSecret, sessionTokens, ...safeFields } = user;
    return safeFields;
    // TypeScript vérifie que safeFields est bien de type SafeUser
}

Record<K, V> — mapper clés et valeurs

Record<K, V> construit un type objet dont toutes les clés sont de type K et toutes les valeurs de type V. La puissance de Record vient de son utilisation avec des union types littéraux en clé : TypeScript force l'exhaustivité de toutes les clés.

Record vs index signature — différence critique

// Index signature : les clés sont n'importe quelle string (non exhaustif)
type MapA = { [key: string]: number };
const mapA: MapA = {}; // OK — aucune clé requise

// Record avec union littérale : toutes les clés DOIVENT être présentes
type Status = 'pending' | 'processing' | 'completed' | 'failed';
type StatusLabels = Record<Status, string>;

const labels: StatusLabels = {
    pending: 'En attente',
    processing: 'En cours',
    completed: 'Terminé',
    // failed: '...' ← TypeScript ERROR: Property 'failed' is missing
};

Record pour les lookup tables (pattern très courant)

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

// Mapping méthode HTTP → code de succès par défaut
const defaultSuccessCodes: Record<HttpMethod, number> = {
    GET:    200,
    POST:   201,
    PUT:    200,
    PATCH:  200,
    DELETE: 204,
};

// Lookup instantané sans if/switch
function getSuccessCode(method: HttpMethod): number {
    return defaultSuccessCodes[method];
}

Record pour modéliser un état par entité

interface Product {
    id: number;
    name: string;
    price: number;
    stock: number;
}

// Dictionnaire d'entités indexé par ID — pattern NgRx EntityState simplifié
type ProductMap = Record<number, Product>;

// Ou avec une union de statuts par entité
type LoadingState = 'idle' | 'loading' | 'success' | 'error';
type ProductLoadingMap = Record<number, LoadingState>;

// Exemple d'utilisation dans un composant Angular
const productLoadingStates: ProductLoadingMap = {
    1: 'success',
    2: 'loading',
    3: 'error',
};
Record<string, T> vs Record<PropertyKey, T> : PropertyKey est le type union string | number | symbol. Pour les dictionnaires indexés par ID numérique, préfère Record<number, T> pour plus de précision.

Partial<T> et Required<T> — contrôle de l'optionnalité

Partial<T> rend toutes les propriétés optionnelles (ajoute ?). Required<T> les rend toutes obligatoires (retire ?). Ces deux types sont inverses l'un de l'autre.

Partial pour les endpoints PATCH

interface UserProfile {
    displayName: string;
    bio: string;
    avatarUrl: string;
    timezone: string;
    notificationsEnabled: boolean;
}

// Un PATCH peut modifier n'importe quel sous-ensemble
type PatchUserDto = Partial<UserProfile>;
// { displayName?: string; bio?: string; avatarUrl?: string; ... }

async function patchProfile(userId: number, changes: PatchUserDto): Promise<void> {
    // On n'envoie que les champs fournis (Object.keys filtre les undefined)
    const payload = Object.fromEntries(
        Object.entries(changes).filter(([, v]) => v !== undefined)
    );
    await fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        body: JSON.stringify(payload),
    });
}

Required pour contraindre après initialisation

// Configuration avec valeurs par défaut appliquées progressivement
interface ChartConfig {
    width?: number;
    height?: number;
    title?: string;
    showLegend?: boolean;
    colors?: string[];
}

// Après application des défauts, toutes les valeurs sont garanties
type ResolvedChartConfig = Required<ChartConfig>;

function resolveConfig(partial: ChartConfig): ResolvedChartConfig {
    return {
        width: partial.width ?? 800,
        height: partial.height ?? 400,
        title: partial.title ?? '',
        showLegend: partial.showLegend ?? true,
        colors: partial.colors ?? ['#3b82f6', '#10b981', '#f59e0b'],
    };
}

Limitation de Partial : non récursif (shallow only)

interface DeepConfig {
    server: {
        host: string;
        port: number;
    };
    database: {
        url: string;
        poolSize: number;
    };
}

// Partial ne descend pas — server et database sont optionnels mais leurs
// propriétés internes restent obligatoires si l'objet est fourni
type ShallowPartial = Partial<DeepConfig>;
// { server?: { host: string; port: number }; database?: { url: string; poolSize: number } }

// DeepPartial custom (voir section "Créer ses propres Utility Types")
type DeepPartial<T> = { [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] };

Readonly<T> — immutabilité à la compilation

Readonly<T> ajoute le modificateur readonly sur toutes les propriétés de T. Toute tentative de modification provoque une erreur de compilation. Important : cela n'a aucun effet à l'exécution — ce n'est pas un Object.freeze().

Readonly pour les états immuables (Angular Signals, NgRx)

interface AppState {
    user: { id: number; name: string } | null;
    theme: 'light' | 'dark';
    sidebarOpen: boolean;
}

// Le state ne doit jamais être muté directement
type ImmutableAppState = Readonly<AppState>;

function reducer(state: ImmutableAppState, action: Action): ImmutableAppState {
    // state.theme = 'dark'; ← TypeScript ERROR: Cannot assign to 'theme' (readonly)

    // On retourne toujours un nouvel objet
    return { ...state, theme: 'dark' };
}

ReadonlyArray vs Readonly pour les tableaux

// Tableau en lecture seule — ni push, ni pop, ni splice
const permissions: ReadonlyArray<string> = ['read', 'write', 'admin'];
// ou syntaxe équivalente :
const permissions2: readonly string[] = ['read', 'write', 'admin'];

permissions.push('superadmin'); // TypeScript ERROR
const filtered = permissions.filter(p => p !== 'admin'); // OK — retourne un nouveau tableau

// Readonly<T> avec un objet contenant un tableau
type Config = Readonly<{
    apiUrl: string;
    allowedOrigins: string[];
}>;
// apiUrl et allowedOrigins sont readonly, mais le contenu du tableau reste mutable
// Pour immutabilité profonde, il faut ReadonlyArray explicitement
Readonly en pratique Angular : les Angular Signals exposent leurs données en lecture seule par défaut. Utilise Readonly<T> sur les types de retour des computed() et des toSignal() pour rendre cette intention explicite.

ReturnType, Parameters, ConstructorParameters

Ces trois Utility Types permettent d'inférer les types à partir des fonctions plutôt que de les redéclarer manuellement. Ils sont précieux pour wrapper des fonctions existantes, créer des adapters ou typer des mocks de test.

ReturnType — inférer le type de retour

// Sans ReturnType : on duplique le type de retour
function fetchUser(id: number): Promise<{ id: number; name: string; email: string }> {
    return fetch(`/api/users/${id}`).then(r => r.json());
}
// Dupliquer le type de retour crée une divergence risquée lors des refactors

// Avec ReturnType : on infère directement
type FetchUserResult = ReturnType<typeof fetchUser>;
// FetchUserResult = Promise<{ id: number; name: string; email: string }>

// Pour extraire le type résolu de la Promise :
type ResolvedUser = Awaited<ReturnType<typeof fetchUser>>;
// ResolvedUser = { id: number; name: string; email: string }

Parameters — inférer les paramètres

function createOrder(
    productId: number,
    quantity: number,
    shippingAddress: string,
    priority: 'standard' | 'express'
): void { /* ... */ }

// Inférer les paramètres sans les réécrire
type CreateOrderParams = Parameters<typeof createOrder>;
// [productId: number, quantity: number, shippingAddress: string, priority: 'standard'|'express']

// Utilisation pour mémoriser ou debouncer une fonction existante
function debounce<F extends (...args: any[]) => any>(fn: F, delay: number) {
    let timer: ReturnType<typeof setTimeout>;
    return function (...args: Parameters<F>) {
        clearTimeout(timer);
        timer = setTimeout(() => fn(...args), delay);
    };
}

const debouncedCreateOrder = debounce(createOrder, 300);
// Parfaitement typé : accept les mêmes arguments que createOrder

ConstructorParameters — pour les classes

class HttpService {
    constructor(
        private baseUrl: string,
        private timeout: number,
        private retries: number
    ) {}
}

type HttpServiceArgs = ConstructorParameters<typeof HttpService>;
// [baseUrl: string, timeout: number, retries: number]

// Utile pour des factories qui délèguent la création
function createHttpService(...args: ConstructorParameters<typeof HttpService>): HttpService {
    return new HttpService(...args);
}

Combiner les Utility Types — patterns avancés

Les Utility Types se combinent naturellement. Les patterns les plus courants en production impliquent des imbrications de 2 à 3 niveaux.

Partial + Omit : le pattern PATCH sécurisé

interface Post {
    id: number;        // jamais modifiable
    authorId: number;  // jamais modifiable
    slug: string;      // généré automatiquement
    title: string;
    content: string;
    tags: string[];
    publishedAt: Date | null;
    createdAt: Date;
    updatedAt: Date;
}

// PATCH: modifiable en optionnel, champs système exclus
type UpdatePostDto = Partial<Omit<Post, 'id' | 'authorId' | 'slug' | 'createdAt' | 'updatedAt'>>;
// { title?: string; content?: string; tags?: string[]; publishedAt?: Date | null }

Pick + Record : typer une map de validateurs

interface SignupForm {
    email: string;
    password: string;
    confirmPassword: string;
    acceptTerms: boolean;
}

type FieldValidator = (value: unknown) => string | null; // null = valide, string = message erreur

// On définit un validateur pour chaque champ du formulaire
type FormValidators = Record<keyof SignupForm, FieldValidator>;

const validators: FormValidators = {
    email: (v) => typeof v === 'string' && v.includes('@') ? null : 'Email invalide',
    password: (v) => typeof v === 'string' && v.length >= 8 ? null : 'Minimum 8 caractères',
    confirmPassword: (v) => typeof v === 'string' && v.length > 0 ? null : 'Requis',
    acceptTerms: (v) => v === true ? null : 'Vous devez accepter les conditions',
};

Readonly + Pick : snapshot immuable pour le state management

interface OrderState {
    items: CartItem[];
    total: number;
    discount: number;
    shippingAddress: string | null;
    paymentStatus: 'pending' | 'authorized' | 'captured' | 'failed';
}

// Vue read-only pour les composants (ils lisent, ne mutent jamais)
type OrderSnapshot = Readonly<Pick<OrderState, 'items' | 'total' | 'paymentStatus'>>;

// Dans un Angular Signal Store
const orderSnapshot = computed<OrderSnapshot>(() => ({
    items: state().items,
    total: state().total,
    paymentStatus: state().paymentStatus,
}));

Créer ses propres Utility Types

TypeScript permet de définir des Utility Types personnalisés en combinant les mapped types, les conditional types et les keyof/typeof operators.

DeepPartial — Partial récursif

type DeepPartial<T> = T extends object
    ? { [K in keyof T]?: DeepPartial<T[K]> }
    : T;

interface AppConfig {
    server: { host: string; port: number; ssl: boolean };
    database: { url: string; poolSize: number; timeout: number };
    cache: { ttl: number; maxItems: number };
}

// Toutes les propriétés à tous les niveaux sont optionnelles
type PartialAppConfig = DeepPartial<AppConfig>;
// { server?: { host?: string; port?: number; ssl?: boolean }; ... }

Nullable — ajouter null à toutes les propriétés

type Nullable<T> = { [K in keyof T]: T[K] | null };

interface UserForm {
    firstName: string;
    lastName: string;
    phone: string;
    birthDate: Date;
}

// Formulaire vide : toutes les valeurs peuvent être null initialement
type UserFormState = Nullable<UserForm>;
// { firstName: string | null; lastName: string | null; ... }

const emptyForm: UserFormState = {
    firstName: null,
    lastName: null,
    phone: null,
    birthDate: null,
};

PickByType — sélectionner par type de valeur

// Sélectionne uniquement les clés dont la valeur est assignable à Type
type PickByType<T, Type> = {
    [K in keyof T as T[K] extends Type ? K : never]: T[K]
};

interface MixedModel {
    id: number;
    name: string;
    price: number;
    isActive: boolean;
    tags: string[];
    createdAt: Date;
}

type StringFields = PickByType<MixedModel, string>;
// { name: string }

type NumberFields = PickByType<MixedModel, number>;
// { id: number; price: number }

Pièges courants et bonnes pratiques

Piège 1 : Omit avec des clés inexistantes ne génère pas d'erreur

interface User { id: number; name: string; email: string; }

// PROBLÈME : 'phone' n'existe pas dans User — TypeScript accepte quand même
type UserA = Omit<User, 'phone'>; // { id: number; name: string; email: string } — identique à User

// Contrairement à Pick qui est strict :
type UserB = Pick<User, 'phone'>;
// TypeScript ERROR: Type '"phone"' does not satisfy the constraint 'keyof User'

Piège 2 : type alias vs interface avec Utility Types

interface UserInterface { id: number; name: string; }
type UserType = { id: number; name: string; };

// Les deux fonctionnent avec les Utility Types
type A = Partial<UserInterface>; // OK
type B = Partial<UserType>;      // OK

// DIFFÉRENCE : les interfaces peuvent être étendues après coup (declaration merging)
// Les types alias ne le peuvent pas — préfère type pour les DTOs dérivés

Piège 3 : Readonly peu profond avec des objets imbriqués

type Config = Readonly<{
    server: { host: string; port: number };
}>;

const config: Config = { server: { host: 'localhost', port: 3000 } };
config.server = { host: '0.0.0.0', port: 80 }; // TypeScript ERROR (readonly)
config.server.port = 80; // OK — server.port n'est PAS readonly !

// Solution : Readonly imbriqué explicite
type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K] };
type StrictConfig = DeepReadonly<{ server: { host: string; port: number } }>;
Bonnes pratiques résumées :
  • Utilise Pick quand tu gardes peu de propriétés, Omit quand tu en exclus peu.
  • Pour les DTOs d'API, combine Partial<Omit<T, 'id' | 'createdAt' | 'updatedAt'>>.
  • Vérifie la profondeur de Partial et Readonly — ils ne sont pas récursifs.
  • Préfère ReturnType et Parameters pour éviter la duplication de types de fonctions.
  • Crée des Utility Types custom uniquement quand le besoin se répète 3+ fois dans le projet.

Awaited<T> — déballer les Promises typées

Introduit en TypeScript 4.5, Awaited<T> unwrap récursivement les Promises imbriquées. C'est ce que await retourne au runtime, exprimé au niveau types.

// Unwrap simple
type T1 = Awaited<Promise<string>>;          // string

// Unwrap récursif
type T2 = Awaited<Promise<Promise<number>>>; // number

// Combinaison avec ReturnType pour les fonctions async
async function fetchUser(id: string) {
    const res = await fetch(`/api/users/${id}`);
    return res.json() as Promise<{ id: string; name: string }>;
}

type FetchUserResult = Awaited<ReturnType<typeof fetchUser>>;
// { id: string; name: string }

// Utilisation typique — typer le résultat d'un appel sans dupliquer
const user: FetchUserResult = await fetchUser('42');

Avant Awaited, il fallait écrire manuellement type Unwrap<T> = T extends Promise<infer U> ? U : T qui ne gérait pas la récursion. Awaited est implémenté avec récursivité jusqu'à la limite du compilateur, robustes pour les chaînes Promise.then(p => p.then(...)) rares mais possibles.

Parameters<F> et ReturnType<F> — typer dynamiquement les fonctions

Ces deux utilities extraient le type des paramètres et du retour d'une fonction existante. Indispensables pour les wrappers, décorateurs, ou consommateurs de libs externes sans duplication de types.

function createUser(name: string, age: number, email?: string): { id: string } {
    return { id: crypto.randomUUID() };
}

// Extraire le type des paramètres en tuple
type CreateUserArgs = Parameters<typeof createUser>;
// [name: string, age: number, email?: string]

// Extraire le type de retour
type CreateUserResult = ReturnType<typeof createUser>;
// { id: string }

// Wrapper qui logge les appels — réutilise les types automatiquement
function withLogging<F extends (...args: any[]) => any>(fn: F): F {
    return ((...args: Parameters<F>) => {
        console.log(`Calling ${fn.name}`, args);
        const result = fn(...args);
        console.log(`Returned`, result);
        return result;
    }) as F;
}

const loggedCreate = withLogging(createUser);
loggedCreate('Alice', 30); // ✓ typé exactement comme createUser

ConstructorParameters<C> et InstanceType<C>

class HttpClient {
    constructor(baseUrl: string, timeout: number = 5000) {}
}

type HttpClientArgs = ConstructorParameters<typeof HttpClient>;
// [baseUrl: string, timeout?: number]

type Client = InstanceType<typeof HttpClient>;
// HttpClient

// Pattern factory typé — sans dupliquer la signature
function createHttpClient(...args: ConstructorParameters<typeof HttpClient>): HttpClient {
    return new HttpClient(...args);
}

Ces utilities sont l'outil clé pour écrire des libs typées sans demander à l'utilisateur de répéter ses types. Le hook React useEffect(callback, deps) utilise Parameters en interne pour valider la signature du callback. Les routers comme React Router v7 utilisent ReturnType pour inférer le shape des loaders.

Utility types métier — patterns d'une application réelle

Au-delà des built-in, les apps de production développent des utility types métier réutilisables. Voici les plus communs.

WithId<T> — ajouter une clé id

type WithId<T> = T & { id: string };

type DraftUser = { name: string; email: string };
type User = WithId<DraftUser>;
// { name: string; email: string; id: string }

PaginatedResponse<T> — réponse API standard

type PaginatedResponse<T> = {
    data: T[];
    meta: {
        page: number;
        pageSize: number;
        total: number;
        hasMore: boolean;
    };
};

type UsersPage = PaginatedResponse<User>;
type OrdersPage = PaginatedResponse<Order>;

ApiResult<T, E> — discriminated union pour gestion d'erreurs

type ApiResult<T, E = string> =
    | { success: true; data: T }
    | { success: false; error: E };

async function safeFetch<T>(url: string): Promise<ApiResult<T>> {
    try {
        const res = await fetch(url);
        if (!res.ok) return { success: false, error: `HTTP ${res.status}` };
        return { success: true, data: await res.json() };
    } catch (e) {
        return { success: false, error: e instanceof Error ? e.message : 'Unknown' };
    }
}

const result = await safeFetch<User>('/api/me');
if (result.success) {
    console.log(result.data.name);
} else {
    console.error(result.error);
}

Branded types — IDs distincts au niveau types

type Brand<T, B> = T & { __brand: B };

type UserId = Brand<string, 'UserId'>;
type OrderId = Brand<string, 'OrderId'>;

function getUserById(id: UserId) { /* ... */ }

const userId = 'u-42' as UserId;
const orderId = 'o-99' as OrderId;

getUserById(userId);  // ✓
getUserById(orderId); // ❌ erreur TS : OrderId not assignable to UserId
getUserById('u-42'); // ❌ erreur TS : string not assignable to UserId

Le pattern branded types permet de distinguer au niveau types des valeurs qui auraient le même type structurel. Évite les bugs classiques où un UserId est passé là où un OrderId est attendu. Coût : un cast explicite à la création, mais sécurité totale ensuite.

Mini-projet appliqué — couche DTO/Repository sans duplication

Voici le cas concret qui justifie à lui seul l'existence des Utility Types : modéliser 10+ variantes d'un même modèle (création, lecture publique, lecture admin, mise à jour, listing paginé, recherche partielle, événement domain) avec une seule source de vérité. Sans utility types : 10 interfaces dupliquées, 1 refactor = 10 modifications. Avec : 1 type source + 10 dérivations automatiques.

1. Source unique : le modèle User complet

// Source of truth — tout dérive d'ici
type User = {
    id: string;
    email: string;
    passwordHash: string;     // sensible, jamais exposé
    fullName: string;
    avatarUrl: string | null;
    role: 'admin' | 'member' | 'guest';
    twoFactorSecret: string | null; // sensible
    emailVerifiedAt: Date | null;
    createdAt: Date;
    updatedAt: Date;
    deletedAt: Date | null;
};

2. Dix variantes dérivées sans dupliquer un seul champ

// Champs jamais exposés au client
type PrivateFields = 'passwordHash' | 'twoFactorSecret' | 'deletedAt';

// (a) DTO de création — pas d'id, pas de timestamps, pas de champs sensibles côté output
type CreateUserDto = Omit<User, 'id' | 'createdAt' | 'updatedAt' | 'deletedAt' | PrivateFields> & {
    password: string; // remplace passwordHash côté input
};

// (b) DTO de mise à jour — tout optionnel sauf l'id
type UpdateUserDto = Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt' | PrivateFields>> & { id: string };

// (c) Vue publique (autres utilisateurs) — minimal
type PublicUser = Pick<User, 'id' | 'fullName' | 'avatarUrl'>;

// (d) Vue self (utilisateur connecté sur son propre profil)
type SelfUser = Omit<User, PrivateFields>;

// (e) Vue admin (back-office) — tout sauf les secrets
type AdminUser = Omit<User, 'passwordHash' | 'twoFactorSecret'>;

// (f) Item de listing — colonnes de la table
type UserListItem = Pick<User, 'id' | 'email' | 'fullName' | 'role' | 'createdAt'>;

// (g) Réponse paginée typée
type UserPage = {
    data: UserListItem[];
    meta: { page: number; pageSize: number; total: number; hasMore: boolean };
};

// (h) Critères de recherche — clés indexables, valeurs string
type UserSearchCriteria = Partial<Pick<User, 'email' | 'fullName' | 'role'>>;

// (i) Événement domain "user créé" — référence + données nécessaires aux consumers
type UserCreatedEvent = {
    type: 'user.created';
    payload: Pick<User, 'id' | 'email' | 'role' | 'createdAt'>;
    occurredAt: Date;
};

// (j) Lecture en lecture-seule pour les composants UI — interdire les mutations
type ReadonlyUser = Readonly<PublicUser>;
Test de résilience : renommons fullName en displayName dans le type User. TypeScript signale 9 erreurs de compilation instantanément — exactement les 9 dérivations qui exposent ce champ. Sans utility types, ce refactor aurait demandé 9 grep manuels + 9 edits, avec un risque élevé d'en oublier un.

3. Repository typé — héritage des bénéfices

Le repository générique consomme ces types sans aucune duplication. Le pattern complet est détaillé dans le guide des génériques TypeScript.

interface UserRepository {
    findById(id: string): Promise<User | null>;
    findPaginated(criteria: UserSearchCriteria, page: number): Promise<UserPage>;
    create(dto: CreateUserDto): Promise<User>;
    update(dto: UpdateUserDto): Promise<User>;

    // Vue publique pour les API listing externes
    findPublicById(id: string): Promise<PublicUser | null>;
}

4. Pattern Optional<T, K> custom — propagé dans tout le projet

Quand on veut rendre uniquement certaines clés optionnelles, les utility types built-in ne couvrent pas. On crée son propre helper.

// Custom : rend optionnel UNIQUEMENT les clés K, les autres restent obligatoires
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

// Application : pour la création d'un user, avatarUrl et emailVerifiedAt sont optionnels,
// mais email, fullName, password restent obligatoires
type CreateUserInput = Optional<CreateUserDto, 'avatarUrl' | 'emailVerifiedAt'>;

// Type final résolu :
// { email: string; fullName: string; password: string; role: 'admin'|'member'|'guest';
//   avatarUrl?: string | null; emailVerifiedAt?: Date | null }

5. Sécurité de typage end-to-end avec Zod

Le runtime miroir des utility types se fait avec Zod. Pour le détail des type guards et de la validation à runtime, voir le guide narrowing + Zod.

import { z } from 'zod';

const CreateUserSchema = z.object({
    email: z.string().email(),
    password: z.string().min(12),
    fullName: z.string().min(2),
    avatarUrl: z.string().url().nullable(),
    role: z.enum(['admin', 'member', 'guest']),
    emailVerifiedAt: z.coerce.date().nullable(),
});

// Le type TypeScript correspond exactement au CreateUserDto, dérivé du modèle User
type CreateUserDtoFromZod = z.infer<typeof CreateUserSchema>;

// Vérification compile-time : les deux types sont équivalents
const _check: CreateUserDtoFromZod = {} as CreateUserDto; // doit compiler

Bénéfices mesurés sur un vrai projet

  • Code DTO : ~800 lignes → ~50 lignes (−94 %) pour 12 entités.
  • Refactor d'un champ : 1 modification dans le type source au lieu de 8-12 fichiers.
  • Couverture de typage : 100 % entre frontend, backend, schéma DB (via Drizzle/Prisma).
  • Onboarding : 1 fichier types/user.ts à lire pour comprendre tout l'écosystème de l'entité.

Pour pousser encore plus loin la cohérence de typage avec validation déclarative, lire également les decorators class-validator qui combinent utility types et validation runtime au niveau classe, et le débat type vs interface pour choisir la construction adaptée à chaque déclaration de ce mini-projet.

Partager