Clean code et refactoring - 02 - Fonctions : courtes, claires, responsables

Une fonction doit faire une seule chose et la faire bien. Taille, paramètres, early returns, effets de bord : tout ce qu'il faut savoir.

  1. 01 Clean code et refactoring - 00 - Pourquoi le clean code est un investissement, pas un luxe
  2. 02 Clean code et refactoring - 01 - Nommage : la competence la plus sous-estimee
  3. 03 Clean code et refactoring - 02 - Fonctions : courtes, claires, responsables
  4. 04 Clean code et refactoring - 03 - Conditions et lisibilité : sortir de la pyramide
  5. 05 Clean code et refactoring - 04 - Commentaires et documentation : quand le code ne suffit pas
  6. 06 Clean code et refactoring - 05 - Immutabilite et effets de bord : moins de surprises, moins de bugs
  7. 07 Clean code et refactoring - 06 - Gestion des erreurs propre : fail fast, fail loud
  8. 08 Clean code et refactoring - 07 - Programmation defensive vs offensive : valider aux frontieres, faire confiance a l'intérieur
  9. 09 Clean code et refactoring - 08 - SOLID en pratique avec TypeScript
  10. 10 Clean code et refactoring - 09 - DRY, KISS, YAGNI
  11. 11 Clean code et refactoring - 10 - Couplage et cohesion
  12. 12 Clean code et refactoring - 11 - Complexite cyclomatique
  13. 13 Clean code et refactoring - 12 - Abstractions prematurees vs tardives
  14. 14 Clean code et refactoring - 13 - Code smells
  15. 15 Clean code et refactoring - 14 - Techniques de refactoring
  16. 16 Clean code et refactoring - 15 - Refactoring legacy sans tout casser
  17. 17 Clean code et refactoring - 16 - Tests comme filet de sécurité pour le refactoring
  18. 18 Clean code et refactoring - 17 - Structurer un projet — feature-based vs layer-based
  19. 19 Clean code et refactoring - 18 - Constantes, configuration et magic numbers
  20. 20 Clean code et refactoring - 19 - Linting et formatting — ESLint, Biome, automatiser la qualité
  21. 21 Clean code et refactoring - 20 - Conventions d'équipe et ADR
  22. 22 Clean code et refactoring - 21 - Dette technique — quand elle est acceptable, quand elle tue le projet
  23. 23 Clean code et refactoring - 22 - Code review — donner et recevoir du feedback
  24. 24 Clean code et refactoring - 23 - Glossaire — tous les termes de la serie

02 - Fonctions : courtes, claires, responsables

Ce que tu vas apprendre

  • Quelle taille devrait avoir une fonction
  • La regle de responsabilité unique appliquee aux fonctions
  • La regle des 3 paramètres maximum
  • Le pattern early return pour aplatir les conditions
  • La différence entre fonctions pures et fonctions avec effets de bord

Prerequisites

01 - Nommage


J'ai deja vu une fonction de 1 400 lignes. Pas dans un projet etudiant. Dans un projet en production utilise par 50 000 personnes. Elle s'appelait handleRequest. Elle gerait l'authentification, la validation, le traitement métier, l'envoi d'emails, la mise en cache, le logging et la réponse HTTP. Tout dans un seul bloc. Un collegue l'avait surnommee "la fonction dieu".

Pour corriger un bug dans le calcul de TVA, il fallait comprendre les 1 400 lignes. Parce que des variables declarees a la ligne 47 etaient utilisees a la ligne 1 380. Parce qu'un if a la ligne 200 changeait le comportement a la ligne 900. Le debugger n'aidait meme plus, il fallait tenir le contexte en tête.

Ce jour-la, j'ai compris que la taille d'une fonction est un sujet serieux.

Quelle taille pour une fonction ?

Robert C. Martin recommande 5 a 15 lignes. C'est un ideal, pas un dogme. En pratique, je vise moins de 30 lignes pour le code applicatif et je toléré jusqu'a 50 pour des fonctions de mapping ou de transformation de donnees.

La vraie question n'est pas "combien de lignes" mais "combien de choses". Une fonction qui fait une seule chose en 40 lignes est meilleure qu'une fonction qui fait trois choses en 15 lignes.

typescript// Trop de choses dans une seule fonction
async function handleOrder(req: Request): Promise<Response> {
  // Validation (chose 1)
  if (!req.body.items || req.body.items.length === 0) {
    return new Response("No items", { status: 400 });
  }
  for (const item of req.body.items) {
    if (!item.productId || item.quantity <= 0) {
      return new Response("Invalid item", { status: 400 });
    }
  }

  // Calcul du prix (chose 2)
  let total = 0;
  for (const item of req.body.items) {
    const product = await db.products.findById(item.productId);
    if (!product) return new Response("Product not found", { status: 404 });
    total += product.price * item.quantity;
  }
  const tax = total * 0.2;
  const finalTotal = total + tax;

  // Sauvegarde (chose 3)
  const order = await db.orders.create({
    items: req.body.items,
    total: finalTotal,
    tax,
  });

  return Response.json(order, { status: 201 });
}

Trois responsabilités distinctes. Decomposons :

typescriptasync function handleOrder(req: Request): Promise<Response> {
  const validationError = validateOrderItems(req.body.items);
  if (validationError) {
    return new Response(validationError, { status: 400 });
  }

  const pricing = await calculateOrderTotal(req.body.items);
  if (!pricing.success) {
    return new Response(pricing.error, { status: 404 });
  }

  const order = await saveOrder(req.body.items, pricing);
  return Response.json(order, { status: 201 });
}

La fonction principale raconte une histoire : valider, calculer, sauvegarder. Chaque détail est délégué.

La regle des 3 paramètres

Au-dela de 3 paramètres, une fonction devient difficile a appeler correctement. L'ordre des arguments n'est plus évident. Les erreurs d'inversion se multiplient.

typescript// 6 parametres - impossible de se souvenir de l'ordre
function createUser(
  name: string,
  email: string,
  age: number,
  role: string,
  department: string,
  isActive: boolean
) { ... }

// Appel - lequel est lequel ?
createUser("Alice", "alice@mail.com", 30, "admin", "engineering", true);

La solution : un objet de configuration.

typescripttype CreateUserInput = {
  name: string;
  email: string;
  age: number;
  role: string;
  department: string;
  isActive: boolean;
};

function createUser(input: CreateUserInput) { ... }

// Appel - chaque valeur est nommee
createUser({
  name: "Alice",
  email: "alice@mail.com",
  age: 30,
  role: "admin",
  department: "engineering",
  isActive: true,
});

Chaque argument est nomme a l'appel. Impossible de confondre l'age et le department. L'ordre n'a plus d'importance. Et tu peux ajouter des champs optionnels sans casser les appels existants.

Quand garder des paramètres individuels ? Quand il y en a 1 a 3 et que leur rôle est évident :

typescriptfunction add(a: number, b: number): number { ... }
function greet(name: string): string { ... }
function fetchUser(id: string): Promise<User> { ... }

Early returns : aplatir les conditions

Les if imbriques creent la "pyramide du malheur". Chaque niveau d'imbrication augmente la charge cognitive. Compare :

typescript// Pyramide du malheur
function processPayment(order: Order, user: User) {
  if (order) {
    if (order.items.length > 0) {
      if (user) {
        if (user.isActive) {
          if (user.hasPaymentMethod) {
            // enfin, la logique utile
            return chargeUser(user, order.total);
          } else {
            throw new Error("No payment method");
          }
        } else {
          throw new Error("User inactive");
        }
      } else {
        throw new Error("No user");
      }
    } else {
      throw new Error("Empty order");
    }
  } else {
    throw new Error("No order");
  }
}

Avec des early returns :

typescriptfunction processPayment(order: Order, user: User) {
  if (!order) throw new Error("No order");
  if (order.items.length === 0) throw new Error("Empty order");
  if (!user) throw new Error("No user");
  if (!user.isActive) throw new Error("User inactive");
  if (!user.hasPaymentMethod) throw new Error("No payment method");

  return chargeUser(user, order.total);
}

Meme logique. Zero imbrication. Le "happy path" est a la fin, bien visible. Les cas d'erreur sont geres en haut et evacuees immédiatement. On reparle de ce pattern dans l'article sur les conditions.

Effets de bord : les identifier et les isoler

Un effet de bord, c'est quand une fonction modifie quelque chose en dehors de son scope : écrire en base, envoyer un email, modifier une variable globale, muter un paramètre.

typescript// Cette fonction a un effet de bord cache
function calculateDiscount(order: Order): number {
  const discount = order.total > 100 ? 0.1 : 0;
  order.appliedDiscount = discount; // mutation du parametre !
  analytics.track("discount_calculated"); // appel externe !
  return discount;
}

Le problème : l'appelant s'attend a un calcul pur. Il obtient une mutation et un appel réseau en bonus. Separe les deux :

typescript// Fonction pure - aucun effet de bord
function calculateDiscount(total: number): number {
  return total > 100 ? 0.1 : 0;
}

// Effets de bord explicites
function applyDiscount(order: Order): Order {
  const discount = calculateDiscount(order.total);
  return { ...order, appliedDiscount: discount };
}

function trackDiscountEvent(discount: number): void {
  analytics.track("discount_calculated", { discount });
}

Les fonctions pures sont faciles a tester, faciles a raisonner, faciles a composer. On creuse le sujet dans l'article sur l'immutabilité.

Command-Query Separation (CQS)

Le principe est simple : une fonction soit fait quelque chose (command), soit retourne quelque chose (query). Pas les deux.

typescript// Mauvais : cette fonction modifie ET retourne
function addItemAndGetTotal(cart: Cart, item: Item): number {
  cart.items.push(item); // command
  return cart.items.reduce((sum, i) => sum + i.price, 0); // query
}

// Bon : deux fonctions separees
function addItem(cart: Cart, item: Item): Cart {
  return { ...cart, items: [...cart.items, item] };
}

function getTotal(cart: Cart): number {
  return cart.items.reduce((sum, item) => sum + item.price, 0);
}

Comme tout principe, il y a des exceptions. Un array.pop() qui retire un élément et le retourne viole CQS, et c'est acceptable. L'idee est d'y tendre, pas de s'y conformer a 100%. J'en parle dans un article sur la gestion de projets concrets sur paltemps.fr.

Composition de fonctions

Des petites fonctions se composent naturellement :

typescriptfunction normalizeEmail(email: string): string {
  return email.trim().toLowerCase();
}

function isValidEmailFormat(email: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

function isDisposableEmail(email: string): boolean {
  const disposableDomains = ["tempmail.com", "throwaway.io"];
  const domain = email.split("@")[1];
  return disposableDomains.includes(domain);
}

// Composition
function validateEmail(rawEmail: string): {
  isValid: boolean;
  email: string;
  reason?: string;
} {
  const email = normalizeEmail(rawEmail);

  if (!isValidEmailFormat(email)) {
    return { isValid: false, email, reason: "Format invalide" };
  }

  if (isDisposableEmail(email)) {
    return { isValid: false, email, reason: "Email jetable interdit" };
  }

  return { isValid: true, email };
}

Chaque fonction fait une seule chose. Chaque fonction est testable indépendamment. La fonction de composition orchestre le tout.

Résumé

  • Vise moins de 30 lignes par fonction, mais la responsabilité unique prime sur le nombre de lignes
  • Au-dela de 3 paramètres, utilise un objet
  • Les early returns aplatissent les conditions et rendent le happy path visible
  • Isole les effets de bord des fonctions pures
  • Prefere CQS : une fonction modifie OU retourne, pas les deux
  • Des petites fonctions se composent en fonctions plus complexes

Article précédent : 01 - Nommage

Article suivant : 03 - Conditions et lisibilité

Sources

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