Classes et interfaces#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

Classes TypeScript#

Les classes sont au cœur de la programmation orientée objet. TypeScript les enrichit significativement par rapport à JavaScript en permettant d’annoter chaque propriété, chaque paramètre et chaque valeur de retour avec un type précis, offrant ainsi toute la puissance de la vérification statique au sein d’un modèle objet mature.

Une classe TypeScript se définit de la façon suivante :

class Personne {
  nom: string;
  age: number;

  constructor(nom: string, age: number) {
    this.nom = nom;
    this.age = age;
  }

  saluer(): string {
    return `Bonjour, je m'appelle ${this.nom} et j'ai ${this.age} ans.`;
  }
}

const alice = new Personne("Alice", 30);
console.log(alice.saluer());
// "Bonjour, je m'appelle Alice et j'ai 30 ans."

Définition 6 (Classe TypeScript)

Une classe TypeScript est un modèle (blueprint) permettant de créer des objets partageant la même structure et le même comportement. Elle regroupe des propriétés (données) et des méthodes (comportements), toutes annotées par des types. Le compilateur vérifie statiquement que chaque propriété est bien initialisée dans le constructeur et que chaque méthode reçoit les arguments du bon type.

Il existe deux façons d’introduire une propriété dans une classe. La première consiste à la déclarer explicitement en en-tête de la classe, puis à l’initialiser dans le constructeur comme dans l’exemple précédent. La seconde, présentée à la section dédiée aux paramètres raccourcis, fusionne ces deux étapes en une seule déclaration dans la signature du constructeur. Les deux approches sont équivalentes du point de vue du code généré, mais la première est plus explicite et s’impose lorsque les propriétés nécessitent des commentaires JSDoc détaillés ou des décorateurs.

Une propriété peut également recevoir une valeur par défaut directement à sa déclaration, sans passer par le constructeur :

class Compteur {
  valeur: number = 0;
  pas: number = 1;

  incrementer(): void {
    this.valeur += this.pas;
  }

  reinitialiser(): void {
    this.valeur = 0;
  }
}

TypeScript exige par défaut (option strictPropertyInitialization) que toute propriété déclarée sans valeur par défaut soit initialisée dans le constructeur. Si vous déclarez une propriété que vous comptez initialiser ailleurs (par exemple via un décorateur ou une méthode d’injection), vous pouvez utiliser l’opérateur d’assertion de non-nullité ! pour signaler ce fait au compilateur :

class Service {
  // Initialisé par le framework d'injection de dépendances, pas dans le constructeur
  connexion!: Connexion;
}

Modificateurs d’accès#

TypeScript propose quatre niveaux de visibilité pour les propriétés et méthodes d’une classe.

Définition 7 (Modificateurs d’accès)

Les modificateurs d’accès contrôlent depuis quels endroits du programme un membre d’une classe peut être lu ou écrit :

  • public : accessible depuis n’importe où (valeur par défaut si aucun modificateur n’est spécifié).

  • protected : accessible depuis la classe elle-même et depuis ses sous-classes.

  • private : accessible uniquement depuis la classe elle-même (vérification à la compilation uniquement).

  • # (private de classe JavaScript natif) : inaccessible depuis l’extérieur y compris à l’exécution, car le nom du champ est rendu opaque par le moteur JavaScript.

  • readonly : la propriété ne peut être assignée qu’une seule fois, lors de la déclaration ou dans le constructeur.

Voici une illustration de ces modificateurs en pratique :

class CompteBancaire {
  public titulaire: string;
  protected taux: number;
  private _solde: number;
  #pinCode: number;        // vrai champ privé JavaScript
  readonly iban: string;

  constructor(titulaire: string, iban: string, solde: number, pin: number) {
    this.titulaire = titulaire;
    this.iban = iban;
    this._solde = solde;
    this.taux = 0.015;
    this.#pinCode = pin;
  }

  get solde(): number {
    return this._solde;
  }

  deposer(montant: number): void {
    if (montant <= 0) throw new Error("Le montant doit être positif.");
    this._solde += montant;
  }
}

const compte = new CompteBancaire("Alice", "FR76...", 1000, 1234);
console.log(compte.titulaire);   // ✓ public
console.log(compte.solde);       // ✓ via getter
// console.log(compte._solde);   // ✗ Erreur TypeScript
// console.log(compte.#pinCode); // ✗ Erreur (aussi à l'exécution)
// compte.iban = "autre";        // ✗ Erreur : readonly

Remarque 8

La différence entre private TypeScript et # JavaScript est subtile mais importante. private TypeScript n’existe qu’au moment de la compilation : dans le code JavaScript généré, la propriété est parfaitement accessible. Le préfixe # (champ privé de classe, standardisé en ES2022) est, lui, réellement privé à l’exécution grâce à un mécanisme bas niveau du moteur JavaScript. En pratique, # offre des garanties plus fortes mais sa syntaxe est moins courante. Pour la plupart des besoins, private TypeScript est suffisant.

Les accesseurs (get et set) permettent d’exposer une propriété privée de façon contrôlée, en ajoutant une logique de validation :

class Temperature {
  private _celsius: number;

  constructor(celsius: number) {
    this._celsius = celsius;
  }

  get celsius(): number { return this._celsius; }

  set celsius(valeur: number) {
    if (valeur < -273.15) throw new RangeError("Température inférieure au zéro absolu.");
    this._celsius = valeur;
  }

  get fahrenheit(): number {
    return this._celsius * 9 / 5 + 32;
  }
}

Héritage#

TypeScript supporte l’héritage simple via le mot-clé extends. Une sous-classe hérite de toutes les propriétés et méthodes public et protected de sa classe parente, et peut en redéfinir certaines.

Définition 8 (Héritage et classe abstraite)

L”héritage (extends) permet à une classe fille de réutiliser et de spécialiser le comportement d’une classe parente. Une classe abstraite (abstract class) est une classe qui ne peut pas être instanciée directement : elle sert uniquement de modèle commun pour ses sous-classes. Elle peut contenir des méthodes abstraites (abstract), c’est-à-dire des méthodes déclarées sans corps, que chaque sous-classe est obligée d’implémenter.

abstract class Forme {
  readonly couleur: string;

  constructor(couleur: string) {
    this.couleur = couleur;
  }

  abstract aire(): number;
  abstract perimetre(): number;

  decrire(): string {
    return `Forme de couleur ${this.couleur} : aire = ${this.aire().toFixed(2)}, périmètre = ${this.perimetre().toFixed(2)}`;
  }
}

class Cercle extends Forme {
  constructor(readonly rayon: number, couleur: string) {
    super(couleur);
  }

  aire(): number {
    return Math.PI * this.rayon ** 2;
  }

  perimetre(): number {
    return 2 * Math.PI * this.rayon;
  }
}

class Rectangle extends Forme {
  constructor(
    readonly largeur: number,
    readonly hauteur: number,
    couleur: string
  ) {
    super(couleur);
  }

  aire(): number {
    return this.largeur * this.hauteur;
  }

  perimetre(): number {
    return 2 * (this.largeur + this.hauteur);
  }
}

const c = new Cercle(5, "rouge");
const r = new Rectangle(4, 6, "bleu");
console.log(c.decrire());
// "Forme de couleur rouge : aire = 78.54, périmètre = 31.42"
console.log(r.decrire());
// "Forme de couleur bleu : aire = 24.00, périmètre = 20.00"

Remarque 9

Le principe de substitution de Liskov (LSP) est un pilier de la conception orientée objet : partout où l’on attend un objet de type Forme, on doit pouvoir utiliser un Cercle ou un Rectangle sans que le programme se comporte incorrectement. TypeScript applique ce principe structurellement : puisque Cercle et Rectangle implémentent toutes les méthodes abstraites de Forme et respectent ses contrats, ils peuvent être passés à n’importe quelle fonction acceptant une Forme. Le tableau Forme[] peut contenir indifféremment des cercles et des rectangles.

Lorsqu’une sous-classe redéfinit une méthode, elle peut appeler l’implémentation parente via super :

class CarreFonce extends Rectangle {
  constructor(cote: number) {
    super(cote, cote, "noir");
  }

  decrire(): string {
    return `Carré (${super.decrire()})`;
  }
}

Implémentation d’interfaces#

Définition 9 (Implements)

Le mot-clé implements indique qu’une classe s’engage à satisfaire le contrat décrit par une ou plusieurs interfaces. Contrairement à extends (héritage d’implémentation), implements ne transmet aucun code : c’est un engagement purement structurel. Une classe peut implémenter plusieurs interfaces simultanément, séparées par des virgules.

interface Serialisable {
  serialiser(): string;
}

interface Cloneable<T> {
  cloner(): T;
}

class Document implements Serialisable, Cloneable<Document> {
  constructor(
    public titre: string,
    public contenu: string
  ) {}

  serialiser(): string {
    return JSON.stringify({ titre: this.titre, contenu: this.contenu });
  }

  cloner(): Document {
    return new Document(this.titre, this.contenu);
  }
}

La distinction entre implements et extends est fondamentale :

  • extends crée une relation d’héritage : la sous-classe reçoit toutes les méthodes et propriétés de la classe parente.

  • implements crée un contrat : la classe s’oblige à définir chaque membre déclaré par l’interface, sans rien hériter.

Exemple 4 (Différence implements / extends)

interface Volant {
  voler(): void;
}

class Oiseau {
  respirer(): void {
    console.log("Inspiration...");
  }
}

// Hérite de respirer() ET s'engage à implémenter voler()
class Aigle extends Oiseau implements Volant {
  voler(): void {
    console.log("L'aigle plane.");
  }
}

Une classe peut aussi implémenter une interface définie comme un type alias (type) du moment qu’il s’agit d’un type objet, pas d’une union ou d’une primitive.

Membres statiques#

Les membres statiques appartiennent à la classe elle-même, et non à ses instances.

Définition 10 (Membres statiques)

Un membre statique est déclaré avec le mot-clé static. Il est accessible via le nom de la classe (MaClasse.membre) et non via une instance. Les propriétés statiques sont partagées entre toutes les instances et sont idéales pour les compteurs, les caches ou les constantes de classe.

class Singleton {
  private static instance: Singleton | null = null;
  private static compteurCreations = 0;

  private constructor(public readonly id: number) {
    Singleton.compteurCreations++;
  }

  static getInstance(): Singleton {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton(1);
    }
    return Singleton.instance;
  }

  static getCompteur(): number {
    return Singleton.compteurCreations;
  }
}

const s1 = Singleton.getInstance();
const s2 = Singleton.getInstance();
console.log(s1 === s2); // true
console.log(Singleton.getCompteur()); // 1

TypeScript (depuis ES2022) supporte également les blocs d’initialisation statiques, qui permettent d’exécuter du code arbitraire lors du chargement de la classe pour initialiser des membres statiques complexes :

class Configuration {
  static readonly parametres: Map<string, string>;
  static readonly version: string;

  static {
    Configuration.parametres = new Map([
      ["langue", "fr"],
      ["fuseau", "Europe/Paris"],
    ]);
    Configuration.version = "2.0.0";
    console.log("Configuration initialisée.");
  }
}

Paramètres de constructeur raccourcis#

TypeScript offre une syntaxe concise qui fusionne la déclaration de propriété et son initialisation dans le constructeur en un seul geste.

Définition 11 (Paramètres raccourcis)

Lorsqu’un modificateur d’accès (public, protected, private, readonly) est placé devant un paramètre du constructeur, TypeScript crée automatiquement une propriété de classe du même nom et l’initialise avec la valeur reçue. Cette syntaxe est strictement équivalente à déclarer la propriété séparément et à l’assigner manuellement dans le constructeur.

// Version longue (équivalente)
class PointLong {
  public x: number;
  public y: number;
  private label: string;

  constructor(x: number, y: number, label: string) {
    this.x = x;
    this.y = y;
    this.label = label;
  }
}

// Version raccourcie
class Point {
  constructor(
    public x: number,
    public y: number,
    private label: string
  ) {}

  decrire(): string {
    return `${this.label}(${this.x}, ${this.y})`;
  }
}

const p = new Point(3, 4, "P");
console.log(p.decrire()); // "P(3, 4)"

Cette syntaxe est très répandue dans les projets TypeScript modernes et dans les composants Angular, où les services sont injectés directement via les paramètres du constructeur.

Classes génériques#

Les classes peuvent être paramétrées par des types, de la même façon que les fonctions ou interfaces génériques. Ce mécanisme évite la duplication de code tout en conservant la sûreté des types.

Définition 12 (Classe générique)

Une classe générique déclare un ou plusieurs paramètres de type entre chevrons (<T>) après son nom. Ces paramètres de type sont utilisables dans les signatures de propriétés, de méthodes et de constructeurs, et sont résolus lors de l’instanciation ou de l’inférence.

class Pile<T> {
  private elements: T[] = [];

  empiler(element: T): void {
    this.elements.push(element);
  }

  depiler(): T {
    if (this.elements.length === 0) {
      throw new Error("La pile est vide.");
    }
    return this.elements.pop()!;
  }

  sommet(): T | undefined {
    return this.elements[this.elements.length - 1];
  }

  get taille(): number {
    return this.elements.length;
  }
}

const pileNombres = new Pile<number>();
pileNombres.empiler(1);
pileNombres.empiler(2);
pileNombres.empiler(3);
console.log(pileNombres.depiler()); // 3

const pileChaines = new Pile<string>();
pileChaines.empiler("alpha");
pileChaines.empiler("beta");

On peut ajouter des contraintes sur le paramètre de type pour garantir que les objets manipulés possèdent certaines propriétés :

interface AvecId {
  id: number;
}

class Depot<T extends AvecId> {
  private items = new Map<number, T>();

  ajouter(item: T): void {
    this.items.set(item.id, item);
  }

  trouver(id: number): T | undefined {
    return this.items.get(id);
  }

  lister(): T[] {
    return Array.from(this.items.values());
  }
}

interface Utilisateur extends AvecId {
  nom: string;
  email: string;
}

const depot = new Depot<Utilisateur>();
depot.ajouter({ id: 1, nom: "Alice", email: "alice@exemple.fr" });
depot.ajouter({ id: 2, nom: "Bob",   email: "bob@exemple.fr" });
console.log(depot.trouver(1)?.nom); // "Alice"

Remarque 10

La contrainte T extends AvecId ne signifie pas que T doit être une sous-classe de AvecId : en TypeScript, le système de types est structurel, non nominal. Cela signifie que tout type qui possède au moins une propriété id: number satisfait cette contrainte, qu’il déclare ou non extends AvecId. Ce comportement est cohérent avec la philosophie duck typing du langage.

Diagramme de classes UML#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

fig, ax = plt.subplots(figsize=(14, 9))
ax.set_xlim(0, 14)
ax.set_ylim(0, 9)
ax.axis("off")
ax.set_title("Hiérarchie de classes : Forme, Cercle, Rectangle", fontsize=15, fontweight="bold", pad=16)

palette = sns.color_palette("muted")
col_abstract = palette[3]   # violet/rose — classe abstraite
col_concrete = palette[0]   # bleu — classes concrètes
col_border_abstract = "#7d3c98"
col_border_concrete = "#1a5276"
col_arrow = "#2c3e50"

def draw_class_box(ax, x, y, width, height, title, subtitle, members, color, border_color, is_abstract=False):
    """Dessine une boîte de classe UML."""
    # Fond principal
    box = patches.FancyBboxPatch(
        (x, y), width, height,
        boxstyle="round,pad=0.08",
        linewidth=2.0,
        edgecolor=border_color,
        facecolor=color,
        alpha=0.18
    )
    ax.add_patch(box)
    border = patches.FancyBboxPatch(
        (x, y), width, height,
        boxstyle="round,pad=0.08",
        linewidth=2.0,
        edgecolor=border_color,
        facecolor="none"
    )
    ax.add_patch(border)

    # En-tête (stéréotype + nom)
    header_h = 0.95
    header = patches.FancyBboxPatch(
        (x, y + height - header_h), width, header_h,
        boxstyle="round,pad=0.05",
        linewidth=0,
        edgecolor="none",
        facecolor=border_color,
        alpha=0.75,
        zorder=3
    )
    ax.add_patch(header)

    if subtitle:
        ax.text(x + width / 2, y + height - 0.32, subtitle,
                ha="center", va="center", fontsize=7.5,
                color="white", style="italic", zorder=4)
        ax.text(x + width / 2, y + height - 0.70, title,
                ha="center", va="center", fontsize=10,
                fontweight="bold", color="white", zorder=4)
    else:
        ax.text(x + width / 2, y + height - 0.50, title,
                ha="center", va="center", fontsize=10,
                fontweight="bold", color="white", zorder=4)

    # Ligne séparatrice
    ax.plot([x + 0.08, x + width - 0.08], [y + height - header_h, y + height - header_h],
            color=border_color, lw=1.5, zorder=4)

    # Membres
    for i, member in enumerate(members):
        y_text = y + height - header_h - 0.35 - i * 0.38
        style = "italic" if member.startswith("# ") else "normal"
        text = member.lstrip("# ")
        ax.text(x + 0.22, y_text, member,
                ha="left", va="center", fontsize=8,
                color="#1c1c1c", fontfamily="monospace", zorder=4)

# ── Classe abstraite Forme ──────────────────────────────────────────
forme_members = [
    "+ couleur : string",
    "────────────────────",
    "+ decrire() : string",
    "«abstract» + aire() : number",
    "«abstract» + perimetre() : number",
]
draw_class_box(
    ax, x=4.5, y=5.2, width=5.0, height=3.4,
    title="Forme", subtitle="«abstract»",
    members=forme_members,
    color=col_abstract, border_color=col_border_abstract,
    is_abstract=True
)

# ── Classe Cercle ────────────────────────────────────────────────────
cercle_members = [
    "+ rayon : number",
    "────────────────────",
    "+ aire() : number",
    "+ perimetre() : number",
]
draw_class_box(
    ax, x=0.8, y=0.8, width=4.5, height=3.2,
    title="Cercle", subtitle=None,
    members=cercle_members,
    color=col_concrete, border_color=col_border_concrete
)

# ── Classe Rectangle ─────────────────────────────────────────────────
rect_members = [
    "+ largeur : number",
    "+ hauteur : number",
    "────────────────────",
    "+ aire() : number",
    "+ perimetre() : number",
]
draw_class_box(
    ax, x=8.7, y=0.8, width=4.5, height=3.2,
    title="Rectangle", subtitle=None,
    members=rect_members,
    color=col_concrete, border_color=col_border_concrete
)

# ── Flèche d'héritage : Cercle → Forme ──────────────────────────────
# Ligne verticale puis horizontale (coude)
ax.annotate(
    "",
    xy=(4.5 + 0.5, 5.2),           # bas de Forme (milieu gauche)
    xytext=(0.8 + 2.25, 0.8 + 3.2),  # haut de Cercle
    arrowprops=dict(
        arrowstyle="-|>",
        color=col_arrow,
        lw=2.0,
        mutation_scale=18,
        connectionstyle="arc3,rad=0.0"
    )
)
# Petite étiquette
ax.text(2.8, 4.65, "extends", ha="center", va="center",
        fontsize=8, color=col_arrow, style="italic",
        bbox=dict(boxstyle="round,pad=0.18", facecolor="white",
                  edgecolor=col_arrow, alpha=0.85))

# ── Flèche d'héritage : Rectangle → Forme ───────────────────────────
ax.annotate(
    "",
    xy=(4.5 + 4.5, 5.2),
    xytext=(8.7 + 2.25, 0.8 + 3.2),
    arrowprops=dict(
        arrowstyle="-|>",
        color=col_arrow,
        lw=2.0,
        mutation_scale=18,
        connectionstyle="arc3,rad=0.0"
    )
)
ax.text(10.2, 4.65, "extends", ha="center", va="center",
        fontsize=8, color=col_arrow, style="italic",
        bbox=dict(boxstyle="round,pad=0.18", facecolor="white",
                  edgecolor=col_arrow, alpha=0.85))

# ── Légende ──────────────────────────────────────────────────────────
legend_x, legend_y = 0.4, 8.1
ax.add_patch(patches.FancyBboxPatch(
    (legend_x - 0.1, legend_y - 0.5), 4.0, 0.9,
    boxstyle="round,pad=0.1", linewidth=1.2,
    edgecolor="#aaaaaa", facecolor="white", alpha=0.9, zorder=5
))
swatch1 = patches.FancyBboxPatch(
    (legend_x, legend_y - 0.1), 0.35, 0.35,
    boxstyle="round,pad=0.04", linewidth=1.5,
    edgecolor=col_border_abstract, facecolor=col_abstract, alpha=0.6, zorder=6
)
ax.add_patch(swatch1)
ax.text(legend_x + 0.52, legend_y + 0.075, "Classe abstraite",
        fontsize=8.5, va="center", zorder=6)

swatch2 = patches.FancyBboxPatch(
    (legend_x + 2.0, legend_y - 0.1), 0.35, 0.35,
    boxstyle="round,pad=0.04", linewidth=1.5,
    edgecolor=col_border_concrete, facecolor=col_concrete, alpha=0.35, zorder=6
)
ax.add_patch(swatch2)
ax.text(legend_x + 2.52, legend_y + 0.075, "Classe concrète",
        fontsize=8.5, va="center", zorder=6)

plt.tight_layout()
plt.show()
_images/1ae0de2861826bd4ae6b674cada5664f7c9226335e413d2eb2288c8888e29db1.png

Résumé#

Dans ce chapitre, nous avons exploré l’ensemble du modèle objet de TypeScript :

  • Les classes TypeScript permettent d’annoter propriétés, constructeurs et méthodes avec des types précis. Les propriétés peuvent être déclarées en en-tête ou initialisées dans le constructeur, avec vérification statique de l’initialisation.

  • Les modificateurs d’accès (public, protected, private, #, readonly) contrôlent la visibilité des membres. La distinction entre private TypeScript (compilation seule) et # JavaScript (exécution) est importante pour les besoins de sécurité runtime.

  • L”héritage (extends) permet la spécialisation, tandis que les classes abstraites définissent des contrats partiels : elles contiennent des méthodes abstraites que les sous-classes doivent obligatoirement implémenter. Le principe de substitution de Liskov s’applique naturellement.

  • implements engage une classe à satisfaire un contrat d’interface sans héritage d’implémentation ; une classe peut implémenter plusieurs interfaces simultanément.

  • Les membres statiques appartiennent à la classe et sont partagés entre toutes les instances. Les blocs d’initialisation statiques permettent des initialisations complexes.

  • La syntaxe de paramètres raccourcis dans le constructeur fusionne déclaration et initialisation de propriétés en une ligne, très utilisée dans les projets Angular et NestJS.

  • Les classes génériques paramétrisent le comportement par un type, évitant la duplication tout en conservant la sûreté statique, avec possibilité de contraindre le paramètre de type via extends.

Dans le chapitre suivant, nous approfondirons le mécanisme des génériques, en abordant les fonctions génériques, les interfaces génériques, la variance et l’opérateur infer qui prépare le terrain pour les types conditionnels.