Design patterns en TypeScript - 06 - Builder : construire des objets complexes pas a pas

Le pattern Builder en TypeScript. Construire des objets avec beaucoup de paramètres optionnels de facon lisible.

06 - Builder : construire des objets complexes pas a pas

Ce que tu vas apprendre

  • Le pattern Builder avec chainage de méthodes (fluent API)
  • Builder vs object spread (et quand préférer l'un ou l'autre)
  • Comment ajouter de la validation a la construction

Prerequisites

Avoir lu l'introduction de la serie et l'article sur la Factory.


Le problème

Un constructeur avec 8 paramètres, dont 5 optionnels. Tu as deja vu ca :

typescriptconst email = new Email(
  "contact@paltemps.fr",   // from
  "client@example.com",    // to
  undefined,               // cc
  undefined,               // bcc
  "Confirmation",          // subject
  undefined,               // text
  "<h1>Merci</h1>",        // html
  [],                      // attachments
  { "X-Priority": "1" },   // headers
  true,                    // trackOpens
);

Dix paramètres. Des undefined au milieu pour les optionnels qu'on ne veut pas. Impossible de savoir ce que represente chaque valeur sans aller lire la signature du constructeur. Et si quelqu'un ajoute un paramètre entre cc et bcc, tous les appels existants cassent silencieusement.

La solution : le Builder

Une classe intermediaire qui construit l'objet pas a pas, avec des méthodes nommees :

typescriptinterface Email {
  from: string;
  to: string;
  cc?: string[];
  bcc?: string[];
  subject: string;
  text?: string;
  html?: string;
  attachments: Attachment[];
  headers: Record<string, string>;
  trackOpens: boolean;
}

class EmailBuilder {
  private from = "";
  private to = "";
  private cc: string[] = [];
  private bcc: string[] = [];
  private subject = "";
  private text?: string;
  private html?: string;
  private attachments: Attachment[] = [];
  private headers: Record<string, string> = {};
  private trackOpens = false;

  setFrom(from: string): this { this.from = from; return this; }
  setTo(to: string): this { this.to = to; return this; }
  addCc(email: string): this { this.cc.push(email); return this; }
  addBcc(email: string): this { this.bcc.push(email); return this; }
  setSubject(subject: string): this { this.subject = subject; return this; }
  setText(text: string): this { this.text = text; return this; }
  setHtml(html: string): this { this.html = html; return this; }
  addAttachment(att: Attachment): this { this.attachments.push(att); return this; }
  setHeader(key: string, value: string): this { this.headers[key] = value; return this; }
  enableTracking(): this { this.trackOpens = true; return this; }

  build(): Email {
    if (!this.from) throw new Error("'from' is required");
    if (!this.to) throw new Error("'to' is required");
    if (!this.subject) throw new Error("'subject' is required");
    if (!this.text && !this.html) throw new Error("'text' or 'html' is required");

    return {
      from: this.from,
      to: this.to,
      cc: this.cc.length > 0 ? this.cc : undefined,
      bcc: this.bcc.length > 0 ? this.bcc : undefined,
      subject: this.subject,
      text: this.text,
      html: this.html,
      attachments: this.attachments,
      headers: this.headers,
      trackOpens: this.trackOpens,
    };
  }
}

L'usage est lisible :

typescriptconst email = new EmailBuilder()
  .setFrom("contact@paltemps.fr")
  .setTo("client@example.com")
  .setSubject("Confirmation de commande")
  .setHtml("<h1>Merci pour ta commande</h1>")
  .addAttachment({ filename: "facture.pdf", content: pdfBuffer })
  .setHeader("X-Priority", "1")
  .enableTracking()
  .build();

Chaque méthode a un nom. Pas de undefined au milieu. L'ordre n'a pas d'importance. Et build() valide que tout est coherent avant de créer l'objet.

Le chainage de méthodes (fluent API)

Le truc qui rend le Builder agreable, c'est le return this dans chaque méthode. Ca permet de chainer : .setFrom(...).setTo(...).setSubject(...). Sans ca, tu devrais écrire :

typescriptconst builder = new EmailBuilder();
builder.setFrom("contact@paltemps.fr");
builder.setTo("client@example.com");
builder.setSubject("Confirmation");
// Fonctionnel mais verbeux

Les deux approches marchent. Le chainage est plus concis et tres repandu dans l'ecosysteme TypeScript (Drizzle ORM, Zod, Effect, beaucoup de query builders).

Builder vs object spread

En TypeScript, il existe une alternative plus legere pour les objets avec des paramètres optionnels :

typescriptinterface EmailOptions {
  from: string;
  to: string;
  subject: string;
  html?: string;
  text?: string;
  cc?: string[];
  attachments?: Attachment[];
}

const defaults: Partial<EmailOptions> = {
  attachments: [],
};

function sendEmail(options: EmailOptions): void {
  const config = { ...defaults, ...options };
  // ...
}

// Usage
sendEmail({
  from: "contact@paltemps.fr",
  to: "client@example.com",
  subject: "Confirmation",
  html: "<h1>Merci</h1>",
});

C'est plus simple. Pas de classe Builder, pas de méthodes a chainer. TypeScript vérifié les types au compile time. Ca couvre beaucoup de cas.

Alors, quand utiliser un Builder plutot que l'object spread ?

  • L'objet a de la validation complexe pendant la construction (pas juste "ce champ est requis", mais "si html est present, text est optionnel, sinon text est requis")
  • L'objet se construit incrementalement (tu ajoutes des pieces au fil du temps, pas en une seule expression)
  • Tu as des méthodes d'accumulation comme addAttachment, addCc qui ajoutent a une liste
  • Tu veux un objet immutable une fois construit (le Builder est mutable, l'objet final ne l'est pas)

Pour un simple config object avec 4-5 champs optionnels ? Object spread. Pour un email avec validation, pieces jointes accumulees et headers dynamiques ? Builder.

Exemple concret avec paltemps.fr

Sur paltemps.fr, la construction d'un email de newsletter nécessité plusieurs étapes : récupérer le template, injecter les variables, attacher les images inline, configurer les headers de tracking. Un Builder simplifie cette construction qui se fait en plusieurs étapes dans différentes parties du code :

typescriptconst builder = new NewsletterBuilder()
  .setTemplate(template)
  .setRecipient(subscriber.email);

// Plus tard, dans un autre module
for (const article of selectedArticles) {
  builder.addArticle(article);
}

// Encore plus tard
if (subscriber.preferences.includeImages) {
  for (const img of articleImages) {
    builder.addInlineImage(img);
  }
}

const email = builder.build();

L'objet se construit en plusieurs endroits du code, pas en une seule expression. C'est là où le Builder brille par rapport a l'object spread.

Quand ne PAS utiliser un Builder

Si ton objet a un constructeur simple (2-4 paramètres nommes), un Builder est excessif. Si tu peux utiliser un object literal avec TypeScript qui vérifié les types, fais-le.

typescript// Pas besoin de Builder pour ca
const user: User = { name: "Nicolas", email: "nico@example.com" };

Un Builder a un coût : une classe supplementaire a maintenir, des méthodes a ajouter pour chaque nouveau champ. Assure-toi que la complexité de l'objet justifie ce coût.


Résumé

  • Le Builder construit un objet complexe étape par étape avec des méthodes nommees
  • Le chainage (return this) rend l'API fluide et lisible
  • Pour des objets simples, l'object spread TypeScript suffit
  • Le Builder brille quand il y a de la validation, de l'accumulation, ou une construction incrementale

Article précédent : 05 - Adapter

Article suivant : 07 - Decorator : ajouter des comportements sans modifier le code

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.