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.
Vue d'ensemble des Utility Types built-in TypeScript 5.x
| Utility Type | Ce qu'il fait | Cas d'usage typique |
|---|---|---|
Pick<T, K> | Garde seulement les clés K de T | DTO, vue partielle |
Omit<T, K> | Supprime les clés K de T | Exclure champs sensibles |
Partial<T> | Rend toutes les propriétés optionnelles | PATCH d'API, formulaires |
Required<T> | Rend toutes les propriétés obligatoires | Validation finale, contraindre les optionnels |
Readonly<T> | Rend toutes les propriétés en lecture seule | State immutable, config |
Record<K, V> | Mappe des clés K vers un type V | Dictionnaires, lookup tables |
ReturnType<F> | Infère le type de retour d'une fonction | Wrapper, mock, adapter |
Parameters<F> | Infère les paramètres d'une fonction | HOF, decorators |
NonNullable<T> | Supprime null et undefined de T | Aprè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 à U | Filtrer 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');
}
}
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
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',
};
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<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 } }>;
- Utilise
Pickquand tu gardes peu de propriétés,Omitquand 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
ReturnTypeetParameterspour é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>;
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.