← Retour au blog
TypeScript

Type-Driven Development : le TDD dont personne ne parle

Branded types, illegal states unrepresentable, parse don't validate : comment tirer un maximum du compilateur en combinant Type-Driven et Test-Driven Development.

📅 ✍️ Antoine Coulon
type-driven-developmentbranded-typessum-typesparse-dont-validatetypescript

On connaît tous le Test-Driven Development. Il existe pourtant un autre TDD, tout aussi important et efficace, dont on parle beaucoup moins : le Type-Driven Development. L’idée est simple : tirer un maximum de valeur du compilateur de notre langage statiquement typé préféré pour écarter une large classe de problèmes avant même d’exécuter le code.

Si vous travaillez avec un langage statiquement typé comme TypeScript, C#, Scala ou Kotlin, alors le Type-Driven Development est déjà à portée de main. Il y a quelques années, le débat JavaScript vs TypeScript faisait encore rage. Aujourd’hui, TypeScript s’impose comme un standard évident, et c’est une excellente nouvelle. Mais au-delà du typage basique qui permet d’éviter les bugs classiques, il existe des concepts avancés qui poussent l’utilisation du système de types à un autre niveau. Voici trois patterns que vous devriez exploiter dès maintenant.

Branded Types : distinguer ce que les primitives confondent

Les Branded Types (et plus largement les Value Objects) permettent de représenter des concepts que le compilateur est capable de distinguer, même lorsque les primitives sous-jacentes sont identiques.

Prenons un cas concret. Un UserId et un StoreId sont tous deux, techniquement, des string. Rien n’empêche donc de les intervertir par mégarde dans un appel de fonction, et le compilateur ne dira rien, puisque pour lui ce ne sont que deux chaînes de caractères. C’est exactement le genre d’erreur silencieuse qui finit en bug de production.

L’astuce consiste à « marquer » (brand) chaque type avec une étiquette unique au niveau du type, ce qui émule le nominal typing dans un langage structurellement typé comme TypeScript. Dès lors, UserId et StoreId ne sont plus interchangeables aux yeux du compilateur.

/**
 * BRANDED TYPES
 * https://effect.website/docs/code-style/branded-types/
 */

const storeId = "ada6967e-1f85-4363-816f-9f2b7f92b251";
const userId = "fddbe760-9a8e-4ef2-834e-fa0b0220fc83";

function registerInStore(userId: string, storeId: string) {}

// ❌ Les deux sont des strings, donc facilement interchangeables
registerInStore(storeId, userId);

import { Brand } from "effect";

// ✅ On utilise des Branded Types pour permettre à TypeScript de
//    les différencier au type-level, en émulant du nominal typing
type StoreId = string & Brand.Brand<"StoreId">;
const StoreId = Brand.nominal<StoreId>();

type UserId = string & Brand.Brand<"UserId">;
const UserId = Brand.nominal<UserId>();

function safeRegisterInStore(userId: UserId, storeId: StoreId) {}

// ✅ Désormais, on ne peut plus les interchanger car ils sont
//    différents aux yeux de TypeScript
safeRegisterInStore(StoreId(storeId), UserId(userId));
//                  ^^^^^^^^^^^^^^^^
// Argument of type 'StoreId' is not assignable to parameter of type 'UserId'.

L’inversion accidentelle des arguments, qui passait inaperçue avec de simples string, devient une erreur de compilation. Le bug n’a même plus l’occasion d’exister.

Rendre les états illégaux irreprésentables

Le deuxième pattern consiste à s’assurer que les types reflètent des états qui doivent être exclusifs plutôt que cumulables. On s’appuie pour cela sur les Sum Types (union types en TypeScript, sealed traits ailleurs).

L’exemple canonique est celui d’une facture (Bill). Une facture peut être « payée », auquel cas les données du paiement (date, montant payé) sont disponibles. Ou elle est « impayée », et alors seul le montant restant dû a du sens. Ces deux configurations ne doivent jamais coexister.

/**
 * MAKE ILLEGAL STATES UNREPRESENTABLE
 * https://fsharpforfunandprofit.com/posts/designing-with-types-making-illegal-states-unrepresentable/
 */

interface PaidBill {
  type: "paid";
  paidAt: Date;
  amountPaid: number;
}

type UnpaidBill = {
  type: "unpaid";
  amountDue: number;
};

type Bill = PaidBill | UnpaidBill;

function payBill(bill: UnpaidBill): PaidBill {
  // ✅ On ne peut payer une Bill que si elle n'est pas encore payée
  return { type: "paid", paidAt: new Date(), amountPaid: bill.amountDue };
}

function generateInvoice(bill: PaidBill): string {
  // ✅ On ne peut générer une facture que pour une Bill payée
  return `
    Invoice for a paid bill ${bill.amountPaid} on ${bill.paidAt.toISOString()}
    Thank you for your payment!
    Your bill is now settled.
  `;
}

Avec cette modélisation, payBill n’accepte qu’une UnpaidBill et generateInvoice n’accepte qu’une PaidBill. Le compilateur garantit qu’on ne tentera jamais de payer une facture déjà réglée, ni de facturer une facture impayée. La règle métier est encodée dans les types eux-mêmes.

À l’inverse, voici l’anti-pattern : un type « fourre-tout » où tous les champs sont optionnels et où le statut n’est qu’une chaîne. Rien n’empêche alors de construire des objets incohérents, et chaque fonction doit défensivement vérifier l’état à l’exécution.

// ❌ Ne permet pas de modéliser les états illégaux au type-level
type AmbiguousBill = {
  status: "paid" | "unpaid";
  amountDue?: number;
  amountPaid?: number;
  paidAt?: Date;
};

const nonsense: AmbiguousBill = {
  status: "paid",
  amountDue: 1000, // ❌ incohérent avec "paid" = illegal state
};

function ambiguousPayBill(bill: AmbiguousBill): AmbiguousBill {
  if (bill.status === "unpaid") {
    return { status: "paid", amountPaid: bill.amountDue };
  } else {
    // ❌ Illegal state : on ne peut pas payer une facture déjà payée
    throw new Error("Cannot pay a bill that is already paid.");
  }
}

function ambiguousGenerateInvoice(bill: AmbiguousBill) {
  // ❌ Obligé de vérifier l'état de la facture pour générer une facture
  if (bill.status === "paid" && bill.amountPaid && bill.paidAt) {
    return `
      Invoice for a paid bill ${bill.amountPaid} on ${bill.paidAt.toISOString()}
      Thank you for your payment!
      Your bill is now settled.
    `;
  }
  // ❌ Illegal state : on ne peut pas générer une facture pour une facture non payée
  return "illegal_state";
}

La différence est frappante. Dans la version ambiguë, les états illégaux sont représentables : il faut donc multiplier les gardes runtime, lever des exceptions et inventer des branches « impossibles » qui retournent un "illegal_state". Dans la version typée correctement, ces cas n’existent tout simplement pas : le compilateur les a éliminés à la conception.

Parse, don’t validate

Le troisième pattern part d’un constat : lorsque vous venez d’effectuer une validation à l’exécution sur un objet ou une propriété, cette information doit être exprimée et préservée au niveau du type. Sinon, vous la jetez aussitôt acquise.

L’exemple le plus parlant est celui d’un tableau non vide. Une fonction qui vérifie qu’un tableau n’est pas vide mais retourne un simple Array vous fait perdre l’information : à la ligne suivante, le compilateur ne sait toujours pas que le tableau contient au moins un élément. En revanche, retourner un NonEmptyArray encode la vérification dans le type, et tout le reste du programme peut en bénéficier.

/**
 * PARSE, DON'T VALIDATE
 * https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/
 */
const someArray = [1, 2, 3, 4, 5];

type NonEmptyArray<T> = [T, ...Array<T>];

function toNonEmptyArray<T>(array: T[]): NonEmptyArray<T> {
  if (array.length === 0) {
    throw new Error("Array cannot be empty");
  }

  return array as NonEmptyArray<T>;
}

function main() {
  // ❌ Validation : l'information en sortie de validation n'est
  //    pas reflétée dans le type system
  if (someArray.length > 0) {
    const item = someArray[0];
    //    ^ number | undefined
  }

  // ✅ Parsing : l'information est encodée dans le type system
  // 👉 Nécessite { "noUncheckedIndexedAccess": true } dans
  //    le tsconfig.json
  const array = toNonEmptyArray([1, 2, 3]);
  const item = array[0];
  //    ^ number
}

La nuance est subtile mais décisive. Dans la branche « validation », même après avoir vérifié someArray.length > 0, l’accès someArray[0] reste typé number | undefined : la vérification n’a rien appris au compilateur. Dans la branche « parsing », toNonEmptyArray transforme la donnée en un type qui porte la garantie de non-vacuité ; l’accès array[0] est alors typé number, sans undefined. (À noter : pour que l’indexation soit correctement typée comme potentiellement undefined au départ, l’option noUncheckedIndexedAccess doit être activée dans le tsconfig.json.)

Parser plutôt que valider, c’est ne jamais laisser une information durement acquise au runtime s’évaporer avant le type-check suivant.

Le duo gagnant : Type-Driven + Test-Driven Development

Dans son billet « Type Wars », Robert C. Martin (Uncle Bob) défendait l’idée que le Test-Driven Development associé à un langage dynamiquement typé finirait par remplacer les langages statiquement typés. Mon point de vue est différent : les deux approches ne s’opposent pas, elles se complètent, et on a tout à gagner à les combiner.

Utiliser les deux TDD en symbiose est extrêmement puissant, car cela revient à disposer de deux copilotes en temps réel qui vous montrent la voie :

Là où le Test-Driven Development valide ce que le code fait, le Type-Driven Development restreint en amont ce que le code peut faire. Les états illégaux disparaissent à la conception ; les tests se concentrent alors sur la logique métier réellement intéressante, sans gaspiller leur énergie à couvrir des cas que les types ont déjà rendus impossibles.

Combiner les deux est le meilleur moyen d’obtenir une boucle de feedback extrêmement affûtée : davantage de productivité, davantage d’efficacité, et un logiciel nettement plus robuste.

Conclusion

Le Type-Driven Development n’est pas une alternative au Test-Driven Development : c’est son complément naturel. Branded Types, états illégaux irreprésentables et « parse, don’t validate » sont trois leviers concrets pour faire du compilateur un allié actif plutôt qu’un simple vérificateur de syntaxe. Chacun déplace une catégorie d’erreurs du runtime vers le compile-time, là où elles coûtent le moins cher à corriger.

L’investissement dans le système de types se rembourse à chaque refactoring, à chaque nouvelle fonctionnalité, à chaque membre de l’équipe qui découvre le code. Alors la prochaine fois que vous écrivez : string un peu trop vite, demandez-vous : est-ce vraiment une chaîne quelconque, ou bien un concept que le compilateur pourrait garder pour vous ?