Front-end

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.

Questions fréquentes

Ces utility types évitent la duplication de types dans les architectures en couches. `Pick` crée un type avec uniquement ces deux champs — idéal pour les réponses API légères. `Omit` exclut les champs sensibles — parfait pour les DTOs publics. `Partial` rend tous les champs optionnels pour les endpoints de mise à jour partielle (PATCH). Composez-les : `type UpdateUserDto = Partial` modélise précisément ce qu'un client peut modifier sans jamais dupliquer la définition de base.

`Record` est équivalent à `{ [key in K]: V }` mais plus expressif et plus court. Utilisez `Record` pour les dictionnaires génériques à éviter le `any`. `Record` garantit que toutes les valeurs d'une union sont couvertes — c'est un guard d'exhaustivité à la compilation. L'index signature `{ [key: string]: V }` est plus permissive (accepte toute string) tandis que `Record` avec une union contraint strictement les clés. Dans les switch/object map patterns, `Record` est la forme idiomatique TypeScript.

`Required` transforme tous les champs optionnels (`?`) en obligatoires — utile pour valider que les données reçues sont complètes après traitement. `Readonly` rend tous les champs non-réassignables à la compilation, complémentaire de `Object.freeze()` à runtime. `NonNullable` élimine `null` et `undefined` d'un type : `NonNullable` donne `string`. Ces types sont superficiels (shallow) : `Readonly` ne protège pas les propriétés d'objets imbriqués — créez `DeepReadonly` via types conditionnels pour une immutabilité profonde.

Les utility types built-in sont implémentés en Mapped Types : `type Optional = Omit & Partial` rend optionnels uniquement certains champs. `type Mutable = { -readonly [K in keyof T]: T[K] }` supprime le readonly avec le modifier `-`. `type Nullable = { [K in keyof T]: T[K] | null }` rend tous les champs nullables. `type FlattenObject = { [K in keyof T as T[K] extends object ? never : K]: T[K] }` filtre les propriétés objet. Ces patterns avec les Template Literal Types (`type EventName = \`on${Capitalize}\``) permettent des transformations de types très avancées.

Pick et Omit opèrent sur les CLÉS d'un type objet : Pick garde les clés spécifiées, Omit les retire. Exclude et Extract opèrent sur les MEMBRES d'une union : Exclude retire les types assignables à U, Extract garde uniquement ceux assignables. Exemple : type Public = Pick filtre l'objet ; type Status = Exclude = "loading"|"success"|"error" filtre l'union. Confusion fréquente : Omit utilise une union de strings, Exclude utilise un type union.

Awaited (TS 4.5+) unwrap récursivement les Promises imbriquées. Awaited donne string. Awaited donne number. C'est ce que retourne await en runtime mais exprimé au niveau types. Cas pratique : type FetchResult = Awaited récupère le type de données retourné par une fonction async sans dupliquer. Remplace les anciens hacks comme T extends Promise ? U : T qui ne géraient pas la récursion.

Pattern PATCH classique : seuls certains champs modifiables, tous optionnels. type UpdateUserDto = Partial. Le client peut envoyer { name: "Alice" } ou { email: "x@y.com", bio: "..." } ou tout combinaison partielle. Au lieu de définir manuellement chaque variation, l'utility composé garantit la cohérence avec le modèle User. Si User change, UpdateUserDto évolue automatiquement. C'est le pattern standard des API REST/tRPC modernes.

TypeScript n'a pas de built-in pour cela — il faut le composer. type Optional&lt;T, K extends keyof T&gt; = Omit&lt;T, K&gt; &amp; Partial&lt;Pick&lt;T, K&gt;&gt;. Usage : type CreateInput = Optional&lt;User, "avatarUrl" | "bio"&gt; — toutes les clés de User obligatoires SAUF avatarUrl et bio qui deviennent optionnelles. Inversement, pour rendre certaines clés requises seulement : type RequireKeys&lt;T, K extends keyof T&gt; = Omit&lt;T, K&gt; &amp; Required&lt;Pick&lt;T, K&gt;&gt;. Ces deux helpers couvrent 90 % des cas hybrides qu'on rencontre dans les DTO d'API.

Readonly&lt;T&gt; built-in n'est SHALLOW : il bloque la réassignation des clés de premier niveau (user.name = "..." échoue) mais pas des sous-objets (user.address.city = "..." compile sans erreur). Pour une immutabilité profonde, on écrit DeepReadonly avec récursion : type DeepReadonly&lt;T&gt; = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly&lt;T[K]&gt; : T[K] }. Attention aux structures circulaires (Map, Set) qui peuvent dépasser la profondeur de récursion TypeScript (~50 niveaux). Alternative production : libs comme ts-toolbelt ou type-fest fournissent un ReadonlyDeep testé contre les edge cases.

Marginal en usage normal, significatif si abusé. Coût mesurable sur 3 patterns : (1) chaînes profondes Partial&lt;Required&lt;Omit&lt;Pick&lt;...&gt;&gt;&gt;&gt; — chaque couche force une re-résolution. (2) Mapped types récursifs sans cas de base (DeepReadonly mal écrit) — explosion exponentielle. (3) Unions très larges (50+ membres) combinées à Exclude/Extract — quadratique. Mesure pratique avec --extendedDiagnostics : un type alias dérivé coûte ~0.1 ms ; une chaîne de 6 utility types sur un type de 20 champs coûte ~3-5 ms — invisible. Sur les projets de 500k+ lignes, l'équipe TypeScript recommande de figer les types dérivés avec un type alias intermédiaire pour bénéficier du cache.

Partager