Union, Intersection et Narrowing en TypeScript

1. Aperçu

Union et intersection sont deux outils fondamentaux pour que TypeScript décrive les données de manière plus flexible qu’une simple interface. Narrowing est une technique qui aide le compilateur à « comprendre » le type spécifique d’une valeur à chaque branche du code, plutôt que de vous forcer à convertir le type (as) manuellement.

Cet article couvre les concepts union/intersection, les techniques narrowing courantes (typeof, instanceof, in, discriminated union), jusqu’à l’écriture de type guards personnalisés. À la fin, vous saurez quand utiliser union, quand utiliser intersection, et comment éviter les erreurs Object is possibly undefined ou les conversions de type (as any) qui prolifèrent dans le code.

2. Union type

Union (|) signifie qu’une valeur peut appartenir à l’un de plusieurs types.

type PaymentMethod = "cash" | "card" | "e-wallet";

function describe(method: PaymentMethod): string {
  return `Paiement par : ${method}`;
}

describe("card"); // OK
// describe("paypal"); // Erreur compilation : pas dans l'union

PaymentMethod n’accepte que 3 chaînes de caractères littérales spécifiques. Si vous passez une valeur différente, TypeScript signale une erreur à la compilation, sans attendre le runtime.

Union fonctionne aussi avec des types d’objets :

interface CashPayment {
  type: "cash";
  amount: number;
}

interface CardPayment {
  type: "card";
  amount: number;
  cardNumber: string;
}

type Payment = CashPayment | CardPayment;

Une variable de type Payment peut être CashPayment ou CardPayment, mais TypeScript vous permet uniquement d’accéder aux champs communs des deux (ici type et amount) si vous n’avez pas encore fait de narrowing.

function printAmount(p: Payment) {
  console.log(p.amount); // OK, champ commun
  // console.log(p.cardNumber); // Erreur : n'existe pas sur CashPayment
}

Remarque : Union ne fusionne pas les champs ensemble. Pour accéder aux champs individuels, vous devez d’abord faire du narrowing (voir section 4).

3. Intersection type

Intersection (&) signifie qu’une valeur doit satisfaire simultanément tous les types combinés.

interface Identifiable {
  id: string;
}

interface Timestamped {
  createdAt: Date;
}

type Order = Identifiable & Timestamped & {
  customerName: string;
  total: number;
};

const order: Order = {
  id: "ORD-001",
  createdAt: new Date(),
  customerName: "Nguyễn Văn A",
  total: 150_000,
};

Order possède tous les champs de Identifiable, Timestamped et de l’objet littéral ajouté. Si vous manquez n’importe quel champ, le compilateur signale une erreur.

Intersection est utile quand vous voulez « mélanger » plusieurs petits mixins en un grand type, similaire à la composition en POO mais au niveau du type.

type AuditInfo = { updatedBy: string };

type AuditedOrder = Order & AuditInfo;

const audited: AuditedOrder = {
  ...order,
  updatedBy: "admin",
};

3.1. Intersection avec des types primitifs en conflit

Si deux types dans une intersection sont en conflit sur une primitive, le résultat est never :

type Impossible = string & number; // type never, aucune valeur ne satisfait

Ce cas est généralement un signe que vous avez accidentellement tapé & au lieu de |.

4. Narrowing : réduire le type dans chaque branche du code

Narrowing est le processus par lequel le compilateur déduit automatiquement un type plus spécifique en fonction des conditions de vérification dans le code (if, switch, typeof…).

4.1. Narrowing avec typeof

À utiliser pour les unions composées de types primitifs.

function formatId(id: string | number): string {
  if (typeof id === "number") {
    return id.toFixed(0); // id est réduit à number
  }
  return id.toUpperCase(); // id est réduit à string
}

Dans la branche if, TypeScript sait que id est un number donc permet d’appeler .toFixed(). Dans la branche else (implicite), id n’est que string.

4.2. Narrowing avec instanceof

À utiliser pour les unions composées de classes.

class ValidationError extends Error {
  constructor(public field: string) {
    super(`Le champ ${field} n'est pas valide`);
  }
}

function handleError(err: Error | ValidationError): void {
  if (err instanceof ValidationError) {
    console.log(`Erreur validation du champ : ${err.field}`);
  } else {
    console.log(`Autre erreur : ${err.message}`);
  }
}

err.field n’existe que sur ValidationError, donc le compilateur permet d’y accéder uniquement dans la branche où vous avez vérifié instanceof.

4.3. Narrowing avec in

À utiliser quand les types dans l’union se distinguent par des champs individuels (pas de champ discriminant commun).

interface Dog {
  bark: () => void;
}

interface Cat {
  meow: () => void;
}

function makeSound(pet: Dog | Cat): void {
  if ("bark" in pet) {
    pet.bark(); // réduit à Dog
  } else {
    pet.meow(); // réduit à Cat
  }
}

L’opérateur in vérifie si une propriété existe sur l’objet à l’exécution, et le compilateur utilise ce résultat pour réduire le type correspondant.

4.4. Discriminated union — la manière la plus sûre

C’est le pattern le plus courant dans le code réel : ajouter un champ literal commun (généralement appelé kind ou type) pour distinguer les « branches » dans l’union.

interface SuccessResult {
  status: "success";
  data: string;
}

interface ErrorResult {
  status: "error";
  message: string;
}

type ApiResult = SuccessResult | ErrorResult;

function handleResult(result: ApiResult): void {
  switch (result.status) {
    case "success":
      console.log(`Données : ${result.data}`);
      break;
    case "error":
      console.log(`Erreur : ${result.message}`);
      break;
  }
}

Le compilateur utilise le champ status comme « discriminant » : dans case "success", result est réduit à SuccessResult ; dans case "error", il est réduit à ErrorResult. C’est la manière que TypeScript recommande pour toute union plus complexe que 2 branches, car switch supporte aussi la vérification d’exhaustivité (section 4.6).

4.5. Narrowing avec la vérification de véracité et l’optional chaining

interface Customer {
  name: string;
  phone?: string;
}

function callCustomer(customer: Customer): void {
  if (customer.phone) {
    // customer.phone est réduit de string | undefined à string
    console.log(`Appeler : ${customer.phone}`);
  } else {
    console.log("Le client n'a pas de numéro de téléphone");
  }
}

La vérification de véracité (if (customer.phone)) élimine undefined (et la chaîne vide, mais cela n’a pas d’importance pour un numéro de téléphone).

4.6. Vérification d’exhaustivité avec never

Quand vous ajoutez une nouvelle branche à l’union et oubliez de la traiter, vous voulez que le compilateur signale une erreur immédiatement au lieu d’un bug silencieux à l’exécution. Utilisez une variable de type never dans la branche default :

function assertUnreachable(x: never): never {
  throw new Error(`Cas non géré : ${JSON.stringify(x)}`);
}

function handleResultSafe(result: ApiResult): string {
  switch (result.status) {
    case "success":
      return result.data;
    case "error":
      return result.message;
    default:
      return assertUnreachable(result); // Si on ajoute une branche et l'oublie, erreur à la compilation ici
  }
}

Si quelqu’un ajoute ultérieurement PendingResult à ApiResult en oubliant d’ajouter le case correspondant, result dans la branche default ne sera plus never, et le compilateur signalera une erreur à la compilation — pas besoin d’attendre que le bug ne se manifeste en production.

5. Type guard personnalisé

Quand la logique de vérification est plus complexe qu’une simple expression, vous pouvez écrire une fonction qui retourne un type x is T pour réutiliser le narrowing.

interface PaidOrder {
  status: "paid";
  paidAt: Date;
}

interface PendingOrder {
  status: "pending";
}

type OrderState = PaidOrder | PendingOrder;

function isPaid(order: OrderState): order is PaidOrder {
  return order.status === "paid";
}

function printReceipt(order: OrderState): void {
  if (isPaid(order)) {
    console.log(`Payé le : ${order.paidAt.toISOString()}`);
  } else {
    console.log("Non payé");
  }
}

order is PaidOrder est un prédicat de type. Le compilateur fait confiance au résultat de la fonction isPaid pour réduire order à PaidOrder dans la branche if, même si la logique de vérification se trouve dans une fonction séparée.

6. Vérification par test

Narrowing est une fonctionnalité de compilation, donc « tester » revient vraiment à exécuter tsc sans erreurs. Mais vous devriez toujours écrire des tests pour le comportement d’exécution correspondant.

npm install -D vitest typescript
import { describe, it, expect } from "vitest";

function totalAmount(payment: CashPayment | CardPayment): number {
  return payment.amount;
}

describe("Narrowing des paiements", () => {
  it("calcule correctement le montant pour un paiement en espèces", () => {
    const cash: CashPayment = { type: "cash", amount: 50_000 };
    expect(totalAmount(cash)).toBe(50_000);
  });

  it("calcule correctement le montant pour un paiement par carte", () => {
    const card: CardPayment = {
      type: "card",
      amount: 200_000,
      cardNumber: "4111111111111111",
    };
    expect(totalAmount(card)).toBe(200_000);
  });
});

L’exécution de npx vitest run donne :

✓ Narrowing des paiements > calcule correctement le montant pour un paiement en espèces
✓ Narrowing des paiements > calcule correctement le montant pour un paiement par carte

Test Files  1 passed (1)
     Tests  2 passed (2)

7. Erreurs courantes et quand ne pas utiliser

  • Abus de as pour éviter le narrowing : si vous vous retrouvez constamment à convertir le type (as SomeType) pour « plaire » au compilateur, il y a de fortes chances que votre union/type guard soit mal conçu. Revenez à l’utilisation de discriminated union.
  • Intersection de deux interfaces ayant des champs de même nom mais de types différents : TypeScript essaiera de les fusionner, mais si les types entrent en conflit (par exemple un côté string, l’autre number), ce champ devient never — très facile de causer des erreurs difficiles à comprendre. Donnez toujours des noms de champs cohérents lors de la conception des mixins.
  • Union trop large (par exemple string | number | boolean | object) : cela rend nécessaire le narrowing partout où vous utilisez le type, le code devient confus. Si une union a plus de 4–5 branches sans rapport, revérifiez votre modèle de domaine — vous pourriez devoir la scinder en plusieurs fonctions/surcharges séparées.
  • Oublier default avec never dans switch : vous perdez le bénéfice de la vérification d’exhaustivité, c’est facile de manquer une branche en étendant l’union.
  • Opérateur in sur un objet avec une prototype chain complexe : in vérifie également les propriétés héritées, ce qui peut donner des résultats de narrowing inattendus pour les classes avec une inheritance profonde. Dans ce cas, instanceof ou un champ discriminant est plus fiable.

Remarque : Narrowing fonctionne bien uniquement quand TypeScript peut le prouver par le flux de contrôle statique. Si vous assignez une valeur narrowed à une autre variable via un callback asynchrone (setTimeout, promise .then), le type narrowed peut être « oublié » — TypeScript ne suit pas à travers les limites de closure de manière absolue dans tous les cas.

8. Conclusion

Union décrit « l’un de plusieurs types », intersection décrit « tous les types à la fois » — ces deux outils sont les fondations pour construire un modèle de domaine précis en TypeScript. Narrowing (typeof, instanceof, in, discriminated union, type guard personnalisé) vous aide à exploiter l’union de façon sûre sans avoir besoin de convertir le type manuellement.

Règle pratique : préférez la discriminated union quand il y a 2 branches ou plus, ajoutez toujours une vérification d’exhaustivité avec never pour switch, et évitez les intersections entre des types avec des champs de même nom mais de types différents. Appliqué correctement, vous attraperez beaucoup d’erreurs logiques au moment de l’écriture du code, avant qu’elles n’aient la chance de s’exécuter en production.

typescripttype-safetyjavascriptfrontend