Classi astratte e implementazione di interfacce in TypeScript

In TypeScript possiamo usare interfacce e classi astratte per definire delle "regole" che le classi devono seguire.

Un'interfaccia dice solo cosa una classe deve avere: quali proprietà e quali metodi, senza specificare come devono funzionare.

Una classe astratta, invece, può fare un passo in più: oltre a stabilire cosa una classe deve avere, può anche fornire già una parte di codice pronto all'uso, lasciando solo alcune parti da completare alle classi che la estendono.

Per collegare una classe a un'interfaccia utilizziamo implements, mentre per creare una classe che non può essere istanziata direttamente utilizziamo abstract.

Implementare un'interfaccia

Una classe può implementare un'interfaccia utilizzando la keyword implements.

Per esempio, definiamo un'interfaccia:

interface Persona {
    nome: string;
    saluta(): void;
}

Possiamo creare una classe che la implementa:

class Studente implements Persona {
    nome: string;

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

    saluta(): void {
        console.log(`Ciao, sono ${this.nome}`);
    }
}

La classe Studente deve rispettare la struttura definita da Persona.

Possiamo quindi creare un'istanza:

const studente = new Studente("Andrea");

studente.saluta();

Cosa controlla implements

Quando una classe utilizza implements, TypeScript verifica che la classe possieda le proprietà e i metodi richiesti dall'interfaccia.

Ad esempio:

interface Persona {
    nome: string;
    saluta(): void;
}

Proviamo ora a scrivere una classe senza saluta():

class Studente implements Persona {
    nome: string;

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

In questo esempio, TypeScript segnala un errore perché Studente non implementa completamente Persona.

Lo stesso vale per le proprietà:

class Studente implements Persona {
    saluta(): void {
        console.log("Ciao!");
    }
}

Anche in questo caso manca nome, quindi la classe non rispetta l'interfaccia.

Implementare più interfacce

Una classe può implementare più interfacce contemporaneamente.

È sufficiente separarle con una virgola:

interface Persona {
    nome: string;
}

interface Lavoratore {
    stipendio: number;
}

class Dipendente implements Persona, Lavoratore {
    nome: string;
    stipendio: number;

    constructor(nome: string, stipendio: number) {
        this.nome = nome;
        this.stipendio = stipendio;
    }
}

Dipendente deve quindi rispettare entrambe le interfacce.

Questo permette di combinare diversi contratti all'interno della stessa classe.

Differenza tra implements e extends

È importante distinguere l'uso di implements da extends (che abbiamo visto nella lezione sulle estensione e intersezione di tipi in TypeScript).

Quando utilizziamo implements, la classe non eredita il codice dall'interfaccia. Ad esempio:

class Studente implements Persona {
    // ...
}

Un'interfaccia definisce soltanto una struttura che la classe deve rispettare.

Ad esempio:

interface Persona {
    nome: string;
    saluta(): void;
}

L'interfaccia Persona non contiene un'implementazione di saluta() che possa essere riutilizzata dalla classe. La classe deve quindi fornire autonomamente il metodo:

class Studente implements Persona {
    nome: string;

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

    saluta(): void {
        console.log(`Ciao, sono ${this.nome}`);
    }
}

Classi astratte

Una classe astratta è una classe che può contenere proprietà e metodi, ma che non può essere istanziata direttamente.

In parole povere, una classe astratta è come una bozza o un modello per altre classi. Puoi definirci dentro proprietà e metodi (anche con codice già scritto e funzionante), ma non puoi mai creare direttamente un oggetto a partire da quella classe.

Una classe astratta si definisce utilizzando la keyword abstract:

abstract class Animale {
    nome: string;

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

Non possiamo quindi scrivere:

const animale = new Animale("Fido"); // Errore

TypeScript segnala un errore perché Animale è una classe astratta. Una classe astratta serve invece come base per altre classi.

Estendere una classe astratta

Una classe normale può estendere una classe astratta utilizzando extends.

Ad esempio:

abstract class Animale {
    nome: string;

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

class Cane extends Animale {
    abbaia(): void {
        console.log("Bau!");
    }
}

Ora possiamo creare un Cane:

const cane = new Cane("Fido");

console.log(cane.nome);
cane.abbaia();

Cane eredita quindi la proprietà nome e il costruttore dalla classe Animale.

Metodi astratti

Una classe astratta può contenere anche metodi astratti.

Un metodo astratto viene dichiarato senza implementazione utilizzando abstract:

abstract class Animale {
    abstract emettiVerso(): void;
}

Questo significa che ogni classe normale che estende Animale dovrà fornire un'implementazione di emettiVerso().

Per esempio:

class Cane extends Animale {
    emettiVerso(): void {
        console.log("Bau!");
    }
}

class Gatto extends Animale {
    emettiVerso(): void {
        console.log("Miao!");
    }
}

Entrambe le classi rispettano quindi il requisito imposto dalla classe astratta.


Un metodo astratto permette di stabilire che cosa una classe derivata deve fare, senza stabilire necessariamente come deve farlo.

Ad esempio:

abstract class Forma {
    abstract area(): number;
}

Una classe Rettangolo può implementare il metodo in un modo, mentre una classe Cerchio può invece utilizzare una formula completamente diversa:

class Rettangolo extends Forma {
    constructor(
        private larghezza: number,
        private altezza: number
    ) {
        super();
    }

    area(): number {
        return this.larghezza * this.altezza;
    }
}

class Cerchio extends Forma {
    constructor(
        private raggio: number
    ) {
        super();
    }

    area(): number {
        return Math.PI * this.raggio ** 2;
    }
}

Entrambe sono obbligate a fornire area(), ma possono implementarlo in modo differente.

Il costruttore delle classi astratte

Quando una classe estende una classe astratta che possiede un costruttore, deve inizializzare la parte ereditata attraverso super().

Ad esempio:

abstract class Animale {
    constructor(
        protected nome: string
    ) {}
}

Nota: nell'esempio, si usa protected perché permette alla proprietà nome di essere accessibile all'interno della classe Animale e delle classi che la estendono, ma non direttamente dall'esterno. Lo abbiamo approfondito meglio nella lezione sulle classi.

La classe derivata può utilizzare:

class Cane extends Animale {
    constructor(nome: string) {
        super(nome);
    }
}

Nell'esempio, super(nome) richiama il costruttore della classe Animale.

Prova!Completa gli spazi vuoti con il testo appropriato.
abstract class Dipendente {
    constructor(protected nome: string) {}
}

class Manager extends Dipendente {
    constructor(nome: string) {
        (nome);
    }
}

Prova! Scegli l'opzione corretta tra quelle elencate.
interface Volante {
    vola(): void;
}

class Aquila ________ Volante {
    vola(): void {
        console.log("Sto volando");
    }
}