Le choix des modèles TypeScript en fonction de la complexité qu’ils éliminent.
Une présentation des modèles de conception classiques et des techniques TypeScript au niveau des types, avec des indications claires sur le moment où chacun trouve sa place et quand du code simple est préférable.
La plupart des équipes adoptent TypeScript pour l’autocomplétion et la détection des fautes d’orthographe, avant de découvrir sa véritable valeur : il permet d’enregistrer les décisions de conception afin que le compilateur les applique. Ce guide présente les modèles orientés objet classiques ainsi que les techniques au niveau des types qui sont les plus importantes dans de grands projets frontend et full-stack, et montre comment déterminer quand un modèle en vaut la peine.
Pensez au système de types comme à un outil capable de répondre aux questions architecturales :
- Est-il vraiment possible d’obtenir cette combinaison de valeurs d’état ?
- Cet API pourrait-il renvoyer une structure que le reste du code ne s’attend pas à voir ?
- Un
ProductIdpourrait-il être transmis à une fonction qui attend unUserId? - Un composant pourrait-il recevoir des propriétés contradictoires ?
- Si un nouvel état est ajouté, tous les composants qui l’utilisent seront-ils contraints de le gérer ?
any ?
Cartographier le paysage
TYPESCRIPT PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
CREATIONAL STRUCTURAL BEHAVIORAL
│ │ │
├─ Factory ├─ Adapter ├─ Strategy
├─ Builder ├─ Facade ├─ Observer
├─ Singleton ├─ Decorator ├─ Command
└─ Abstract Factory ├─ Repository └─ State
└─ Composition
+
TYPESCRIPT TYPE PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
Generics Unions Type Safety
│ │ │
├─ Constraints ├─ Discriminated ├─ Type Guards
├─ keyof Unions ├─ Branded Types
├─ typeof ├─ Result Types ├─ Exhaustiveness
├─ infer └─ State Modeling └─ satisfies
└─ Mapped Types
Les applications réelles utilisent rarement un seul motif. Un flux de données typique dans React empile plusieurs d’entre eux, et chaque étape peut comporter des types précis :
React Component
│
▼
Custom Hook
│
▼
Service
│
▼
Repository
│
▼
API Client
│
▼
Result<T, E>
│
▼
Discriminated Union
Lorsque les types circulent le long de toute cette chaîne, TypeScript devient une description de la manière dont le système est autorisé à se comporter.
Partir du problème, pas du motif
Un piège courant est de raisonner dans la mauvaise direction :
"I know Factory Pattern.
Where can I use Factory?"
Choisir un schéma en premier crée des abstractions dont personne n’a besoin. Laissez plutôt le problème guider ce choix :
What problem do I have?
↓
Where is the complexity?
↓
What is changing frequently?
↓
What should remain stable?
↓
What abstraction reduces that complexity?
↓
Is a known pattern appropriate?
Considérez un flux de paiement qui comporte une série de vérifications relatives à la méthode de paiement :
if (paymentMethod === "card") {
// ...
}
if (paymentMethod === "paypal") {
// ...
}
if (paymentMethod === "upi") {
// ...
}
La réaction instinctive est souvent la suivante :
Don’t immediately think:
« Cela nécessite une stratégie. » Avant d’y recourir, demandez-vous si les branches en question sont vraiment des algorithmes interchangeables sous un même contrat. S’ils le sont, la stratégie convient. Si le véritable problème est que certains champs n’ont de sens que pour certaines méthodes, une union discriminée qui rend les combinaisons invalides irreprésentables peut être un outil plus adapté. Connaître ce catalogue est facile ; savoir associer une entrée à une situation spécifique, c’est là la véritable compétence.
Modèles de création
Singleton : une seule instance partagée
Un Singleton garantit qu’une classe dispose d’exactement une instance. Le constructeur privé bloque toute tentative d’accès en dehors de new, et une méthode statique crée et stocke cette instance de manière différée :
class Logger {
private static instance: Logger;
private constructor() {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message: string) {
console.log(message);
}
}
Les appels demandent l’objet partagé au lieu de en créer un :
const logger = Logger.getInstance();
logger.log("Application started");
Tous les utilisateurs finissent par faire référence au même objet :
Logger
│
getInstance()
│
▼
┌───────────┐
│ Logger │
│ Instance │
└───────────┘
▲ ▲
│ │
Service A Service B
Parmi les candidats pertinents on trouve :
- le journalisation
- les gestionnaires d’analyses
- les conteneurs de configuration
- certains gestionnaires de connexions
- d’autres services d’infrastructure transversaux
Le problème, c’est qu’un Singleton n’est en réalité qu’un accès global déguisé : il devient plus difficile d’isoler les tests, les dépendances disparaissent des signatures, le cycle de vie devient flou, et l’état mutable partagé évolue de manières impossibles à tracer. Dans un frontend moderne, une instance exportée depuis un module ES est déjà partagée, et l’injection de dépendances, React Context ou une bibliothèque d’état offrent la même garantie avec un schéma de connexion visible.
Fabrique : cacher quelle classe est créée
Une fabrique retire de la responsabilité du consommateur la décision quant à la classe concrète à instancier. Comparez la construction directe :
const payment = new StripePayment();
avec la construction déléguée :
const payment = PaymentFactory.create("stripe");
Les deux fournisseurs implémentent une même interface, et la fabrique associe une union de littéraux de chaîne à la classe appropriée. Comme le type du paramètre est "stripe" | "paypal", tout nom non pris en charge provoque une compilation échouée :
interface PaymentProvider {
pay(amount: number): Promise<void>;
}
class StripePayment implements PaymentProvider {
async pay(amount: number) {
console.log("Stripe:", amount);
}
}
class PayPalPayment implements PaymentProvider {
async pay(amount: number) {
console.log("PayPal:", amount);
}
}
class PaymentFactory {
static create(
provider: "stripe" | "paypal"
): PaymentProvider {
switch (provider) {
case "stripe":
return new StripePayment();
case "paypal":
return new PayPalPayment();
}
}
}
Visuellement, la usine est une fourche identifiée par le nom du fournisseur :
PaymentFactory
│
┌────────────┴────────────┐
│ │
"stripe" "paypal"
│ │
▼ ▼
StripePayment PayPalPayment
Une usine s’avère utile lorsque :
- la construction implique une véritable logique
- plusieurs implementations partagent un même contrat
- les consommateurs ne doivent pas connaître les classes concrètes
- les implementations doivent évoluer indépendamment des appelsants
Pour une construction triviale comme celle ci-dessous, elle ne fait qu’ajouter une couche supplémentaire à comprendre :
new User();
Usine abstraite : familles d’objets liés
L’usine abstraite étend cette idée aux groupes d’objets qui doivent être compatibles. Dans un kit UI destiné à plusieurs plateformes, un bouton web ne doit jamais être associé à un modal mobile ; ainsi, chaque usine produit une famille cohérente :
interface Button {
render(): void;
}
interface Modal {
open(): void;
}
interface UIFactory {
createButton(): Button;
createModal(): Modal;
}
UIFactory
│
┌────────┴────────┐
▼ ▼
WebUIFactory MobileUIFactory
│ │
┌────┴────┐ ┌────┴────┐
▼ ▼ ▼ ▼
Button Modal Button Modal
C’est puissant, mais il est facile de surconstruire. Dans la plupart des applications frontend, la simple composition de composants permet d’atteindre le même résultat avec moins de formalités.
Builder : construction contrôlée, étape par étape
Le builder est utile lorsque un objet possède de nombreuses options facultatives ou est configuré en plusieurs étapes. Chaque méthode de mise à jour modifie la configuration privée et renvoie this, ce qui permet le chaînage :
class RequestBuilder {
private config: RequestInit = {};
setMethod(method: string) {
this.config.method = method;
return this;
}
setHeaders(headers: HeadersInit) {
this.config.headers = headers;
return this;
}
setBody(body: BodyInit) {
this.config.body = body;
return this;
}
build() {
return this.config;
}
}
Assembler une requête se fait en suivant des étapes explicites :
const request = new RequestBuilder()
.setMethod("POST")
.setHeaders({
"Content-Type": "application/json"
})
.setBody(JSON.stringify(data))
.build();
La syntaxe fluide est un effet secondaire, pas l’objectif principal. L’idée est de rendre la construction complexe explicite et centralisée, afin que build() puisse également valider, par exemple en refusant un corps pour une requête GET.
Modèles structuraux
Adapter : un contrat stable autour de l’API d’un autre système
L’adaptateur est l’un des patterns les plus utilisés dans le code d’application. Supposons que votre code attende ce contrat interne :
interface PaymentGateway {
pay(amount: number): Promise<void>;
}
alors qu’un SDK de tiers expose une méthode nommée différemment :
class LegacyPaymentSDK {
makePayment(value: number) {
// third-party implementation
}
}
Un adaptateur léger implémente votre interface et traduit les appels :
class PaymentAdapter implements PaymentGateway {
constructor(
private readonly sdk: LegacyPaymentSDK
) {}
async pay(amount: number) {
this.sdk.makePayment(amount);
}
}
Application
│
▼
PaymentGateway
▲
│
PaymentAdapter
│
▼
Third-party SDK
L’application dépend désormais d’une interface que vous possédez :
PaymentGateway
plutôt que de dépendre de chaque fournisseur impliqué :
Stripe
PayPal
LegacySDK
SomeFutureProvider
Prendre en charge un nouveau fournisseur signifie écrire un autre adaptateur.
Facade : une seule appel pour un flux de travail en plusieurs étapes
Une Facade met en place un point d’entrée simple devant un sous-système complexe. Le processus de connexion peut impliquer plusieurs services :
Authentication
+
User Service
+
Permissions
+
Notification
+
Analytics
Sans facade, chaque composant chargé de connecter un utilisateur orchestre lui-même la séquence :
auth.login();
user.load();
permission.load();
analytics.track();
Une facade gère cette orchestration une seule fois :
class AppFacade {
async login(username: string, password: string) {
const token = await auth.login(username, password);
const user = await userService.getUser(token);
await permissionService.load(user);
analytics.track("login");
return user;
}
}
Et le composant se réduit à une seule appel :
await appFacade.login(username, password);
Component
│
▼
AppFacade
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Auth User Permission
Service Service Service
Cela est particulièrement utile lorsque l’ordre des étapes compte.
Décorateur : ajouter du comportement en enveloppant
interface Logger {
log(message: string): void;
}
Une implémentation simple :
class ConsoleLogger implements Logger {
log(message: string) {
console.log(message);
}
}
Un décorateur qui conserve n’importe quel Logger et ajoute une horodatage en tête des messages :
class TimestampLogger implements Logger {
constructor(
private readonly logger: Logger
) {}
log(message: string) {
this.logger.log(
`[${new Date().toISOString()}] ${message}`
);
}
}
L’enveloppement n’est rien d’autre qu’une construction :
const logger = new TimestampLogger(
new ConsoleLogger()
);
Puisque chaque décorateur est lui-même un Logger, ils s’empilent :
Logger
│
▼
ConsoleLogger
│
▼
TimestampLogger
│
▼
AdditionalDecorator
La même idée apparaît sous d’autres noms :
- chaînes de middleware
- fonctions d’enveloppement
- composants de haut niveau de React
- journalisation
- couches de mise en cache
- vérifications d’autorisation
Modèles de comportement
Stratégie : algorithmes interchangeables
La stratégie élimine les règles métier à branches. Prenons un type de niveau de client :
type CustomerType =
| "regular"
| "premium"
| "enterprise";
Une implémentation conditionnelle place chaque règle dans une seule fonction :
function calculateDiscount(
type: CustomerType,
price: number
) {
if (type === "regular") {
return price;
}
if (type === "premium") {
return price * 0.9;
}
return price * 0.8;
}
Avec la stratégie, chaque règle devient une classe derrière une interface commune :
interface DiscountStrategy {
calculate(price: number): number;
}
class RegularDiscount implements DiscountStrategy {
calculate(price: number) {
return price;
}
}
class PremiumDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.9;
}
}
class EnterpriseDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.8;
}
}
Order
│
▼
DiscountStrategy
│
┌───────────┼───────────┐
▼ ▼ ▼
Regular Premium Enterprise
Strategy Strategy Strategy
Ajouter un nouveau niveau signifie généralement ajouter une stratégie plutôt que d’éditer la logique existante, ce qui correspond au principe ouvert/fermé en pratique. Pour trois règles simples, l’approche conditionnelle suffit ; la stratégie s’avère utile lorsque les règles développent leurs propres dépendances ou tests.
Observateur : notifications un-à-plusieurs
L’observateur permet à un sujet de notifier un nombre quelconque d’écouteurs lorsqu’il y a un changement :
Subject
│
┌──────────┼──────────┐
▼ ▼ ▼
Observer A Observer B Observer C
Set et renvoie une fonction de nettoyage lorsqu’un écouteur est enregistré :
type Listener<T> = (value: T) => void;
class EventEmitter<T> {
private listeners = new Set<Listener<T>>();
subscribe(listener: Listener<T>) {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
emit(value: T) {
this.listeners.forEach(listener => {
listener(value);
});
}
}
const emitter = new EventEmitter<string>();
const unsubscribe = emitter.subscribe(message => {
console.log(message);
});
emitter.emit("Hello");
unsubscribe();
- des fuites mémoire dues à des écouteurs qui survivent à leurs propriétaires
- un traitement dupliqué lorsque deux écouteurs sont attachés
- des closures obsolètes qui lisent des valeurs dépassées
- des effets secondaires qui se produisent après la disparition d’un composant
Au React, c’est exactement ce que l’on renvoie depuis la fonction de rappel de useEffect.
interface Command {
execute(): void;
}
class SaveCommand implements Command {
execute() {
console.log("Saving...");
}
}
class UndoCommand implements Command {
execute() {
console.log("Undo");
}
}
- un historique de ce qui s’est passé
- des fonctions annuler et réexécuter
L’annulation réelle exige généralement que chaque commande s’inverse elle-même, c’est pourquoi les versions de production ajoutent souvent une méthode undo() à côté de execute().
User Action
│
▼
Command
│
├── execute()
│
▼
Receiver
Modèles de données et de composition
Repository : isoler l’accès aux données
Lorsque l’accès aux données devient complexe, un Repository crée un contrat entre la logique métier et tout élément qui stocke ou récupère les données :
UI
│
▼
Hook / Controller
│
▼
Service
│
▼
Repository
│
├── REST
├── GraphQL
├── IndexedDB
└── Cache
Le contrat décrit ce que l’application peut demander :
interface UserRepository {
getUser(id: string): Promise<User>;
getUsers(): Promise<User[]>;
}
Une implémentation communique avec une API REST :
class ApiUserRepository implements UserRepository {
async getUser(id: string) {
const response = await fetch(`/users/${id}`);
return response.json();
}
async getUsers() {
const response = await fetch("/users");
return response.json();
}
}
Le code métier dépend de l’interface :
UserRepository
et non du mécanisme de transport :
fetch()
axios()
graphqlClient()
Cela est important lorsque la source change : du REST au GraphQL, l’ajout d’un cache IndexedDB, ou une version en mémoire pour les tests. Notez que response.json() renvoie une valeur non typée, donc le répertoire est également l’endroit idéal pour valider les réponses.
Composition plutôt qu’héritage
Dans le développement frontend, la composition est plus importante que tout modèle basé sur l’héritage. Plutôt qu’un seul composant qui gère tout :
MegaComponent
├── Authentication
├── Table
├── Filters
├── Modal
├── Notifications
├── API calls
└── Business logic
divisez les responsabilités en parties bien définies :
Dashboard
├── Header
├── Sidebar
├── FilterPanel
├── DataTable
└── NotificationPanel
Au sein de React, il s’agit simplement d’enchaîner des composants :
<Dashboard>
<Header />
<Sidebar />
<MainContent />
</Dashboard>
Chaque partie peut être comprise, testée et remplacée indépendamment. Pour en savoir plus sur la décomposition des composants trop volumineux, consultez comment résoudre le surcharge de propriétés dans React grâce à la composition et aux slots.
Modélisation de l’état avec le système de types
Désormais, TypeScript lui-même constitue l’outil architectural.
Unions discriminées pour l’état des requêtes
Une requête passe par différentes phases qui contiennent des données distinctes. Une union discriminée attribue à chaque phase sa propre structure, reliées entre elles par un champ littéral status :
type RequestState =
| {
status: "idle";
}
| {
status: "loading";
}
| {
status: "success";
data: User[];
}
| {
status: "error";
error: string;
};
L’utilisation du discriminant permet de restreindre le type dans chaque branche :
function render(state: RequestState) {
switch (state.status) {
case "idle":
return "Nothing started";
case "loading":
return "Loading...";
case "success":
return state.data;
case "error":
return state.error;
}
}
Le compilateur suit quels champs existent après chaque vérification :
status = "success"
↓
data exists
status = "error"
↓
error exists
Comparez avec le modèle courant basé sur des booléens et des valeurs optionnelles :
interface State {
loading: boolean;
data?: User[];
error?: string;
}
Rien ne l’empêche de décrire un état qui ne devrait jamais survenir :
{
loading: true,
data: [...],
error: "Something failed"
}
L’union rend très difficiles l’expression de telles combinaisons. Modélisez plutôt les états valides au lieu de disperser des propriétés optionnelles en comptant sur tout le monde pour les combiner correctement.
Types de résultat pour les échecs explicites
De nombreuses opérations ont exactement deux résultats :
Success
OR
Failure
Un type Result les décrit tous deux à l’aide d’un discriminant booléen :
type Result<T, E> =
| {
success: true;
data: T;
}
| {
success: false;
error: E;
};
Une fonction qui le renvoie :
function getUser(): Result<User, string> {
return {
success: true,
data: user
};
}
L’appelant doit vérifier success avant d’accéder à data ou error :
const result = getUser();
if (result.success) {
console.log(result.data);
} else {
console.error(result.error);
}
Service
│
▼
Result<T, E>
/ \
/ \
▼ ▼
Success Failure
│ │
data error
Cela correspond aux échecs commerciaux attendus tels que « adresse e-mail déjà enregistrée ». Les exceptions conviennent toujours aux véritables bugs et aux pannes d’infrastructure ; le type Result permet simplement de rendre visibles les échecs anticipés dans la signature.
Génériques et outils au niveau du type
Les génériques conservent le lien entre l’entrée et la sortie
L’utilisation de any jette l’information away :
function identity(value: any): any {
return value;
}
Un paramètre de type le conserve :
function identity<T>(value: T): T {
return value;
}
Le résultat inféré suit l’argument :
const a = identity("hello");
// string
const b = identity(100);
// number
La relation entre l’entrée et la sortie reste inchangée :
Input T
│
▼
Function<T>
│
▼
Output T
Les génériques permettent également de réutiliser des contrats communs. Une seule enveloppe de réponse :
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
décrivant de nombreux en-têtes de données :
type UserResponse =
ApiResponse<User>;
type ProductResponse =
ApiResponse<Product>;
Contraintes : exigence de forme
Cela ne compile pas :
function getId<T>(item: T) {
return item.id;
}
car rien n’indique à TypeScript que T possède un id. Une contrainte ajoute cette garantie :
function getId<T extends { id: string }>(
item: T
) {
return item.id;
}
Tout objet disposant d’un id de type chaîne est accepté, y compris les champs supplémentaires :
getId({
id: "123",
name: "Hareesh"
});
Lisez la contrainte de cette manière :
T can be anything
BUT
T must have id: string
keyof et accès par index
Étant donné une interface :
interface User {
id: string;
name: string;
age: number;
}
keyof génère l’union des noms de ses propriétés :
type UserKey = keyof User;
"id" | "name" | "age"
La combinaison de keyof avec un deuxième paramètre de type et le type d’accès indexé T[K] produit un accesseur dont le type de retour correspond à la clé :
function getProperty<T, K extends keyof T>(
object: T,
key: K
): T[K] {
return object[key];
}
const user = {
id: "1",
name: "Hareesh",
age: 30
};
getProperty(user, "name");
Une clé réelle se compile :
getProperty(user, "name");
Une clé manquante provoque une erreur de compilation :
getProperty(user, "salary");
La force réside dans le fonctionnement conjoint de trois outils :
Generics
+
keyof
+
Indexed Access
Types mappés : transformer un type existant
Les types mappés itèrent sur les clés d’un type pour en créer un nouveau. À partir de :
interface User {
id: string;
name: string;
email: string;
}
on peut dériver une version entièrement optionnelle :
type OptionalUser = {
[K in keyof User]?: User[K];
};
conceptuellement équivalent à l’écriture :
{
id?: string;
name?: string;
email?: string;
}
C’est ainsi que des fonctions intégrées comme Partial sont définies.
Types conditionnels : décisions au niveau du type
Les types conditionnels choisissent entre deux types en fonction de leur assignabilité :
T extends U ? X : Y
Celui-ci déballle les types des éléments d’array et laisse tout le reste intact :
type Flatten<T> =
T extends Array<infer U>
? U
: T;
type A = Flatten<string[]>;
// string
type B = Flatten<number>;
// number
À ce stade, le système de types se comporte comme un petit langage en temps de compilation, ce qui exige retenue.
infer : extraire une partie d’un type
Dans un type conditionnel, infer déclare une variable de type que TypeScript remplit par correspondance. Cela réimplémente le ReturnType intégré :
type MyReturnType<T> =
T extends (...args: any[]) => infer R
? R
: never;
Associez-le à typeof pour dériver un type à partir d’une fonction existante, afin qu’il ne s’éloigne jamais de l’implémentation :
function getUser() {
return {
id: "1",
name: "Hareesh"
};
}
type User = MyReturnType<typeof getUser>;
Les définitions de types des bibliothèques dépendent fortement de cela.
D’abord les types d’aide intégrés
Au lieu d’écrire des outils ingénieux, connaissez d’abord ce qui est fourni avec le langage :
Partial
Required
Readonly
Pick
Omit
Record
Exclude
Extract
NonNullable
ReturnType
Parameters
InstanceType
Awaited
Supprimer un champ sensible se fait en une seule ligne, au lieu d’utiliser une interface dupliquée qui risquerait de sortir de synchronisation :
interface User {
id: string;
name: string;
email: string;
password: string;
}
type PublicUser =
Omit<User, "password">;
Le guide sur les types utilitaires intégrés de TypeScript aborde chacun d’eux en profondeur.
Record avec un ensemble fermé de clés
Record convient aux recherches et à la configuration, surtout lorsque les clés proviennent d’une union littérale :
type Permission =
"read" |
"write" |
"delete";
type PermissionMap =
Record<Permission, boolean>;
const permissions: PermissionMap = {
read: true,
write: false,
delete: false
};
Si l’on omet une permission, TypeScript signale la clé manquante. Un type de clé trop large perd cette garantie :
const permissions: Record<string, boolean>
car Record<string, boolean> accepte pratiquement n’importe quelle clé de type chaîne, ce qui fait que les omissions et les fautes d’orthographe passent inaperçues.
Modèles de sécurité pour les identifiants et les données non fiables
Types spécialisés pour les identifiants de domaine
Deux identifiants peuvent être des chaînes de caractères tout en ayant des significations différentes :
const userId: string;
const productId: string;
D’un point de vue structurel, TypeScript ne peut pas les distinguer. L’intersection d’une string avec une propriété fantôme crée des types distincts :
type UserId =
string & {
readonly __brand: "UserId";
};
type ProductId =
string & {
readonly __brand: "ProductId";
};
Les fonctions peuvent alors exiger le bon type d’ID :
function getUser(id: UserId) {}
function getProduct(id: ProductId) {}
Transmettre un ProductId là où un UserId est attendu provoque une erreur de compilation. La marque n’existe jamais en temps de exécution ; on crée des valeurs marquées à l’aide d’un petit constructeur ou d’une fonction de validation qui effectue le cast en un seul endroit. Conceptuellement :
string
│
├── UserId
├── ProductId
├── OrderId
└── TransactionId
Cela s’avère avantageux dans les grands systèmes où des dizaines d’identifiants partagent le même type primitif.
Guard de type pour des entrées unknown
Les données provenant du réseau, du stockage ou des entrées utilisateur doivent arriver sous forme de unknown :
const data: unknown = await response.json();
Un cast constitue le raccourci tentant :
const user = data as User;
Préférez restreindre la valeur à l’aide d’un garde de type défini par l’utilisateur ; son type de retour value is User indique au compilateur ce qu’un résultat true prouve :
function isUser(
value: unknown
): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}
if (isUser(data)) {
console.log(data.name);
}
N’oubliez pas que les types sont effacés en temps de exécution. Une interface comme celle-ci :
interface User {
id: string;
}
ne valide rien de ce que l’API envoie. Le garde mentionné ci-dessus ne vérifie que l’existence des clés, pas leurs types ; par conséquent, pour des entrées non fiables, utilisez TypeScript avec un validateur de schéma en temps de exécution.
Vérification exhaustive avec never
L’une des combinaisons les plus efficaces du langage :
Discriminated Union
+
never
+
switch
Utilisez une union de statuts :
type Status =
| "loading"
| "success"
| "error";
et un outil d’aide qui n’accepte que never :
function assertNever(
value: never
): never {
throw new Error(
`Unexpected value: ${value}`
);
}
Lorsque chaque membre a été traité, la branche par défaut rencontre le type never, ce qui permet à l’appel de se compiler :
function render(status: Status) {
switch (status) {
case "loading":
return "Loading";
case "success":
return "Success";
case "error":
return "Error";
default:
return assertNever(status);
}
}
Un collègue étend maintenant l’union :
Now imagine someone adds:"cancelled" to Status.
La branche par défaut reçoit "cancelled", qui ne peut pas être affecté à never, ce qui provoque l’échec de la compilation tant que ce cas n’est pas géré. Le compilateur devient alors un vérificateur de conception qui se souvient de chaque consommateur.
Types de littéraux de template
TypeScript peut composer des types de littéraux de chaîne à partir d’autres types :
type Entity =
"user" |
"order" |
"product";
type Event =
`${Entity}:created` |
`${Entity}:updated` |
`${Entity}:deleted`;
L’union résultante contient toutes les combinaisons possibles :
user:created
user:updated
user:deleted
order:created
order:updated
order:deleted
product:created
product:updated
product:deleted
Les utilisations pratiques incluent :
- noms d’événements
- clés d’événements analytiques
- chaînes de permissions
- modèles de routes
- noms de flags fonctionnelles
- tokens du système de conception
as const lorsque les valeurs constituent la source de vérité
Un littéral d’array de chaînes est élargi :
const roles = [
"admin",
"editor",
"viewer"
];
Son type est string[]. L’ajout de as const produit une tuple lisible seule composée de littéraux :
const roles = [
"admin",
"editor",
"viewer"
] as const;
d’où découle directement un type union :
type Role =
typeof roles[number];
becomes:
"admin" |
"editor" |
"viewer"
Définissez les valeurs une seule fois et n’entretenez jamais manuellement une union parallèle.
satisfies pour la configuration vérifiée
satisfies valide une expression par rapport à un type sans l’élargir à ce type :
type Config = {
retries: number;
environment:
| "development"
| "production";
};
const config = {
retries: 3,
environment: "production"
} satisfies Config;
L’objet est vérifié par rapport à Config, mais config.environment conserve le type littéral "production", ce que une simple annotation ferait perdre. Cela convient à :
- la configuration des routes
- les flags de fonctionnalité
- les tokens de conception
- les objets de paramètres statiques
- les cartes de permissions
Injection de dépendances
Créer des dépendances à l’intérieur d’une classe la lie à celles-ci :
class UserService {
private api = new ApiClient();
}
Les recevoir via le constructeur permet à la classe de rester agnostique :
class UserService {
constructor(
private readonly api: ApiClient
) {}
}
En environnement de production, c’est le vrai client qui est passé en paramètre :
const service =
new UserService(apiClient);
et pour les tests, c’est un objet simulé qui est utilisé :
const service =
new UserService(mockApiClient);
UserService
▲
│
Dependency
│
┌────────┴────────┐
▼ ▼
ApiClient MockApiClient
Production Testing
C’est l’une des méthodes les moins coûteuses pour obtenir du code testable, et seuls les paramètres du constructeur sont nécessaires.
Distinguer des patterns similaires
Pattern d’état ou union discriminée ?
Pour un cycle de vie d’une commande :
Draft
Paid
Shipped
Cancelled
une union discriminée peut suffire :
type Order =
| { status: "draft" }
| { status: "paid" }
| { status: "shipped" }
| { status: "cancelled" };
Mais lorsque chaque état implémente un comportement important :
Draft
├── edit()
├── submit()
Paid
├── refund()
├── ship()
Shipped
├── track()
└── deliver()
un pattern d’état, où chaque objet d’état met en œuvre les opérations autorisées, peut être plus adapté. Choisissez en fonction du niveau de complexité de chaque état, et non en fonction de la terminologie utilisée.
Stratégie ou état ?
Un sujet fréquent dans les entretiens. Avec la stratégie, le client choisit l’algorithme :
Order
│
▼
Strategy
├── CreditCard
├── PayPal
└── UPI
Avec l’état, l’objet modifie son propre comportement au fil de son cycle de vie :
Order
│
├── Draft
├── Paid
└── Shipped
La stratégie varie la manière dont une tâche est exécutée ; l’état varie ce que fait un objet à son stade actuel.
Fabrique ou stratégie ?
Une fabrique répond à la question « quel objet doit être créé ? » :
PaymentFactory.create("stripe");
Une stratégie répond à la question « quel comportement doit être exécuté ? » :
new Order(discountStrategy);
Ils s’associent naturellement : une fabrique produit la stratégie qui effectue le travail :
Factory
↓
creates
↓
Strategy
↓
executes behavior
Application des patterns en React
Propriétés variantes plutôt que flags booléens
Des booléens indépendants permettent des situations absurdes, comme un bouton à la fois principal et dangereux :
interface ButtonProps {
primary?: boolean;
danger?: boolean;
loading?: boolean;
}
Une union de variantes permet à chacune d’elles de déclarer ses propres exigences :
type ButtonProps =
| {
variant: "primary";
loading?: boolean;
}
| {
variant: "danger";
confirmationRequired: boolean;
};
La variante dangereuse doit maintenant spécifier confirmationRequired.
API des composants qui rejettent les contradictions
href et onClick optionnels permettrait l’utilisation des deux ou de aucun d’eux. Le fragment ci-dessous montre la version non contrôlée, l’alternative différenciée ainsi qu’une utilisation valide :
{
href?: string;
onClick?: () => void;
}
use:
type ActionProps =
| {
type: "link";
href: string;
}
| {
type: "button";
onClick: () => void;
};
Now:
<Action
type="link"
href="/users"
/>
Une variante de lien recevant un gestionnaire de clic au lieu d’un href est rejetée :
<Action
type="link"
onClick={...}
/>
Les modes pris en charge, déclarés clairement :
Link
Button
TypeScript fait désormais partie de l’architecture des composants plutôt que d’une documentation qui devient obsolète.
Un bus d’événements sécurisé par type
Commencez par un tableau associant les noms des événements aux types de données qu’ils transportent :
type Events = {
"user:created": User;
"user:deleted": UserId;
"order:created": Order;
};
Un générique de bus basé sur ce tableau lie chaque nom à sa donnée associée grâce à keyof et T[K] :
class EventBus<T extends Record<string, unknown>> {
on<K extends keyof T>(
event: K,
handler: (data: T[K]) => void
) {
// implementation
}
emit<K extends keyof T>(
event: K,
data: T[K]
) {
// implementation
}
}
Le payload correct se compile :
bus.emit("user:created", user);
Celui qui est incorrect ne se compile pas :
bus.emit("user:created", order);
Plusieurs outils entrent en jeu ici :
Generics
+
keyof
+
Indexed Access
+
Mapped Type thinking
Types dans toute la couche API
Un frontend mature organise souvent l’accès aux données de cette manière :
Component
↓
Hook
↓
Service
↓
Repository
↓
API Client
↓
HTTP
avec des types transmis à chaque étape. Un repository peut accepter des IDs spécifiques et retourner un Result :
type ApiResponse<T> = {
data: T;
status: number;
};
interface UserRepository {
getUser(id: UserId):
Promise<Result<User, ApiError>>;
}
Le composant n’a plus à gérer cela à chaque couche :
any
Les signatures indiquent elles-mêmes ce qui peut réussir, ce qui peut échouer et avec quels types.
Anti-modèles à éviter
any partout
function process(data: any) {}
Un paramètre any désactive les vérifications pour tout ce qu’il touche. Préférez :
function process(data: unknown) {}
et restreignez le type avant utilisation.
Affirmations comme validation
const user =
response.data as User;
Un cast as ne vérifie rien ; il demande simplement au compilateur de vous faire confiance. Validez les données non fiables en temps de exécution.
Les génériques pour eux-mêmes
Évitez des signatures telles que :
function transform<
T,
U,
V,
R
>(...) {}
à moins que chaque paramètre de type ne représente une relation réelle. Un paramètre de type utilisé une seule fois est généralement inutile.
Des unions énormes
Une union discriminée avec une centaine de membres est difficile à naviguer et à faire évoluer. Le domaine peut avoir besoin d’une abstraction différente, comme des unions imbriquées ou le transfert de la variation dans les données.
L’ingéniosité au niveau des types
Lorsqu’un type est plus difficile à comprendre que la logique métier qu’il décrit, reculez d’un pas :
Type complexity
│
▼
Developer complexity
│
▼
Maintenance cost
La sécurité des types a un coût. Visez la sécurité la plus utile par unité de complexité, et non les types les plus sophistiqués.
Un arbre de décision pour le choix
Cet arbre associe les pressions courantes aux modèles candidats :
PROBLEM
│
├── Need to create objects?
│ ├── Simple creation → Constructor
│ ├── Complex creation → Builder
│ ├── Multiple implementations → Factory
│ └── Families of objects → Abstract Factory
│
├── Need to integrate another system?
│ └── Adapter
│
├── Complex subsystem?
│ └── Facade
│
├── Add behavior without modifying object?
│ └── Decorator
│
├── Multiple interchangeable algorithms?
│ └── Strategy
│
├── Subscribers react to changes?
│ └── Observer
│
├── Need actions/history/undo?
│ └── Command
│
├── Data-access abstraction?
│ └── Repository
│
├── Complex state lifecycle?
│ └── State / Discriminated Union
│
└── Type-level problem?
├── Reuse → Generics
├── Transform → Mapped Types
├── Decision → Conditional Types
├── Extract → infer
├── Property safety → keyof
├── Literal safety → as const
├── Contract validation → satisfies
└── Domain safety → Branded Types
Considérez-le comme un point de départ pour la discussion ; le principe « commencer par le simple » reste d’application à chaque nœud feuille.
Que apprendre en premier
Pour les ingénieurs frontend qui se préparent à des postes de niveau senior ou de chef de projet, mémoriser tous les modèles du Gang of Four est un mauvais usage du temps. Une séquence pratique :
Les éléments essentiels :
Discriminated Unions
Generics
Type Guards
keyof
Mapped Types
Utility Types
Composition
Strategy
Repository
Factory
Fortement recommandé ensuite :
Result Type
Branded Types
Conditional Types
infer
Exhaustive Checking
Dependency Injection
Adapter
Facade
Observer
Decorator
Digne d’être compris sur le plan conceptuel :
Builder
Command
State
Abstract Factory
Singleton
Le travail quotidien en frontend repose bien plus sur cette combinaison :
Generics
+
Discriminated Unions
+
Composition
+
Strategy
+
Repository
que sur n’importe quelle version théorique de la Fabrique Abstraite.
Comment le jugement évolue avec l’expérience
Débutant, les ingénieurs se demandent : « Quel motif dois-je utiliser ici ? » Avec de l’expérience dans les systèmes complexes, la question devient : « Quelle est la plus petite abstraction capable de résoudre ce problème sans rendre le système plus difficile à comprendre ? » Le flux de travail axé sur les motifs se présente comme suit :
Problem
↓
Pattern
↓
More classes
↓
More abstractions
Le flux de travail axé sur le problème se présente comme suit :
Problem
↓
Understand volatility
↓
Identify boundary
↓
Start simple
↓
Introduce abstraction only where repetition/change justifies it
De bons motifs émergent lorsque les contraintes de l’architecture deviennent claires ; ils ne sont pas imposés à l’avance.
Questions d’entretien visant à évaluer une véritable compréhension
« Quel est le motif Factory ? » teste la mémorisation. Ces questions évaluent le jugement.
Architecture :
- Front à un composant React de 2 000 lignes, comment choisissez-vous l’abstraction à introduire ?
- Quand refuseriez-vous un motif qui semble approprié ?
- Comment savoir qu’une abstraction est prématurée ?
Fondamentaux de TypeScript :
- Comment modéliser une requête avec des états de chargement, de succès, d’erreur et de tentative de réessai ?
- Comment empêcher les combinaisons invalides de props React ?
- En quoi
unknown,anyetneverdiffèrent-ils ? - Quand choisir
typeplutôt queinterface, et inversement ? - Comment
keyofinteragit-il avec les génériques ?
TypeScript avancé :
- Quel est un cas d’usage concret pour les types conditionnels ?
- Que fait
infer? - Qu’est-ce qu’un type mappé ?
- Quels sont les types conditionnels distributifs ?
- Quand les types marqués sont-ils utiles ?
- Quel problème
satisfiesrésout-il ? - Comment
as constmodifie-t-il l’inférence ?
Conception dans le monde réel :
- Concevoir un bus d’événements sécurisé par les types.
- Concevoir un client API sécurisé par les types.
- Concevoir un système de permissions utilisant TypeScript.
- Concevoir une abstraction de paiement prenant en charge Stripe, PayPal et un troisième fournisseur.
- Comment ajouter une couche Repository à une application React existante sans la réécrire ?
- Comment typifier des événements WebSocket avec des charges utiles différentes ?
- Comment empêcher que un
ProductIdsoit transmis là où unUserIdest requis ?
Une question révélatrice : « Décrivez un moment où vous avez délibérément choisi de ne pas utiliser un schéma de conception. » Une réponse pertinente explique le compromis : avec une seule implémentation et aucune modification probable à anticiper, l’indirection n’aurait pas réduit la complexité, de sorte que le code est resté simple jusqu’à ce qu’un deuxième cas d’usage en justifie l’utilisation.
Un dernier modèle mental
Partez du problème métier, identifiez où réside la complexité, séparez les changements comportementaux des changements structurels, puis laissez la sécurité de type renforcer le résultat.
BUSINESS PROBLEM
│
▼
Identify Complexity
│
▼
What is likely to change?
│
┌──────────┴──────────┐
▼ ▼
Behavior Structure
│ │
Strategy/State Adapter/Facade
│ │
└──────────┬──────────┘
▼
Type Safety
│
┌───────────────┼────────────────┐
▼ ▼ ▼
Generics Unions Utilities
│ │ │
▼ ▼ ▼
keyof Result Type Mapped Types
infer State Model Conditional
satisfies Exhaustive Record
│ │ │
└───────────────┼────────────────┘
▼
SIMPLEER CODE
Points clés
Les patterns fonctionnent le mieux en tant que vocabulaire partagé : « ceci est un adaptateur », « ces comportements sont interchangeables », « ces états appartiennent à une union », « marquez ces IDs », et, ce qui est le plus important, « cette abstraction n’a pas encore mérité sa complexité ». Une bonne implémentation en TypeScript se juge selon l’existence de ces propriétés dans le système, et non selon la ruse de ses types :
Invalid states
↓
become difficult to represent
Changing implementations
↓
don't break consumers
Business rules
↓
are visible in the types
Shared behavior
↓
is reusable without duplication
Complexity
↓
is isolated behind clear boundaries
- Sélectionnez les patterns en fonction de la complexité qu’ils éliminent, et non en fonction de leur familiarité.
- Préférez les unions, les types
Resultet les vérifications exhaustives aux champs optionnels et à l’espoir. - Vérifiez les données aux limites du temps d’exécution ; seuls les types ne vous protègent pas à cet égard.
- Choisissez l’abstraction la plus simple capable de gérer le changement que vous attendez réellement.