Full Stack

Pattern avanzati in TypeScript

Autore

Manuel Ricci

Scrivere un articolo approfondito sui pattern avanzati in TypeScript richiede di esplorare diverse tecniche avanzate di tipizzazione e pattern di progettazione che possono essere utilizzati per sviluppare software più sicuro, mantenibile e scalabile. TypeScript, essendo un superset tipizzato di JavaScript, offre una serie di funzionalità avanzate che permettono agli sviluppatori di catturare molti errori in fase di compilazione e di codificare le intenzioni in modo più chiaro.

Introduzione ai pattern avanzati in TypeScript

TypeScript estende JavaScript aggiungendo tipi statici. Questi tipi permettono agli sviluppatori di utilizzare tecniche avanzate di tipizzazione e pattern di progettazione che non sono possibili in JavaScript puro. L'uso di questi pattern può aiutare a prevenire bug, facilitare la refactoring del codice e migliorare la collaborazione tra gli sviluppatori.

Tipi Avanzati e Utility Types

Prima di addentrarci nei pattern di progettazione, esploriamo alcuni dei tipi avanzati e degli utility types forniti da TypeScript:

Tipi condizionali

I tipi condizionali permettono di costruire tipi in modo condizionale, a seconda dei tipi di ingresso. Ecco un semplice esempio che seleziona un tipo in base a una condizione:

1type IsNumber<T> = T extends number ? "yes" : "no";
2
3type Result1 = IsNumber<42>; // "yes"
4type Result2 = IsNumber<"hello">; // "no"

Questo tipo condizionale controlla se T estende il tipo number e restituisce "yes" se la condizione è vera, altrimenti restituisce "no".

Mapped Types

I mapped types permettono di creare nuovi tipi iterando sulle proprietà di un tipo esistente. Sono utili per trasformare i tipi in modo programmatico:

1type Readonly<T> = {
2    readonly [P in keyof T]: T[P];
3};
4
5type Original = { a: number; b: string; };
6type ReadonlyOriginal = Readonly<Original>;
7
8// ReadonlyOriginal è { readonly a: number; readonly b: string; }

In questo esempio, Readonly<T> è un mapped type che rende tutte le proprietà del tipo T di sola lettura.

Utility Types

TypeScript offre vari utility types per facilitare operazioni comuni sui tipi. Ecco alcuni degli utility types più utilizzati:

Partial

Trasforma tutti i campi di un tipo in campi opzionali:

1interface Todo {
2    title: string;
3    description: string;
4}
5
6type PartialTodo = Partial<Todo>;
7
8// PartialTodo ha entrambe le proprietà title e description come opzionali

Readonly

Già visto nei mapped typesc, rende tutti i campi di un tipo di sola lettura, prevenendo così la loro modifica:

1interface Todo {
2    title: string;
3}
4
5const todo: Readonly<Todo> = { title: "Delete inactive users" };
6
7// Non è possibile modificare title perché è di sola lettura
8// todo.title = "Hello"; // Errore!

Record<K, T>

Crea un tipo che usa un set di chiavi di tipo K e assegna a ciascuna chiave un valore di tipo T. È utile per mappature chiave-valore:

1type Page = "home" | "about" | "contact";
2type PageInfo = { title: string };
3
4const pages: Record<Page, PageInfo> = {
5    home: { title: "Home Page" },
6    about: { title: "About Us" },
7    contact: { title: "Contact Us" }
8};

Pattern di Progettazione

Factory Pattern

Il Factory Pattern è un pattern creazionale che fornisce un'interfaccia per creare oggetti in una superclasse, ma permette alle sottoclassi di alterare il tipo degli oggetti che verranno creati.

Questo pattern è particolarmente utile quando un sistema deve essere indipendente dalle modalità di creazione, composizione e rappresentazione degli oggetti che crea.

Quando si usa questo pattern?

Immagina di avere un'applicazione per la gestione documenti che supporta diversi tipi di file, come documenti di testo, fogli di calcolo e presentazioni. Ogni tipo di documento richiede un diverso tipo di visualizzatore per essere aperto e manipolato all'interno dell'applicazione. Man mano che l'applicazione cresce, potrebbero essere aggiunti supporti per nuovi formati di file, richiedendo flessibilità nella creazione e gestione dei visualizzatori di documenti.

Utilizzare il Factory Pattern permette di astrarre il processo di creazione dei visualizzatori specifici per ogni tipo di documento. Definendo una factory che decide quale visualizzatore creare in base al tipo di documento, l'applicazione può facilmente estendersi per supportare nuovi formati di documenti senza modificare il codice cliente che utilizza i visualizzatori.

1// Interfaccia comune per tutti i visualizzatori di documenti
2interface DocumentViewer {
3    open(): void;
4    close(): void;
5}
6
7// Implementazioni concrete per ogni tipo di visualizzatore
8class TextViewer implements DocumentViewer {
9    open() { console.log("Opening a text document..."); }
10    close() { console.log("Closing the text document."); }
11}
12
13class SpreadsheetViewer implements DocumentViewer {
14    open() { console.log("Opening a spreadsheet document..."); }
15    close() { console.log("Closing the spreadsheet document."); }
16}
17
18class PresentationViewer implements DocumentViewer {
19    open() { console.log("Opening a presentation document..."); }
20    close() { console.log("Closing the presentation document."); }
21}
22
23// Factory per creare il visualizzatore adeguato in base al tipo di documento
24class ViewerFactory {
25    static createViewer(documentType: string): DocumentViewer {
26        switch (documentType) {
27            case "text":
28                return new TextViewer();
29            case "spreadsheet":
30                return new SpreadsheetViewer();
31            case "presentation":
32                return new PresentationViewer();
33            default:
34                throw new Error("Unsupported document type.");
35        }
36    }
37}
38
39// Utilizzo della factory
40const viewer = ViewerFactory.createViewer("spreadsheet");
41viewer.open(); // Output: Opening a spreadsheet document...
42viewer.close(); // Output: Closing the spreadsheet document.

Questo approccio è particolarmente utile in un'applicazione di gestione documenti per garantire che l'aggiunta di supporto per nuovi tipi di documenti sia semplice e non richieda modifiche significative al codice esistente. La factory centralizza la logica di creazione dei visualizzatori, rendendo il sistema più facile da mantenere e estendere.

In questo scenario, il Factory Pattern non solo aiuta a mantenere il codice organizzato e flessibile, ma permette anche di gestire facilmente la complessità associata al supporto di diversi tipi di documenti, dimostrandosi una soluzione efficace per gestire le dipendenze e le istanze di oggetti in maniera dinamica.

Volendo il codice può essere ulteriormente generalizzato con l’uso delle generics come segue:

1// Interfaccia comune per tutti i visualizzatori di documenti
2interface DocumentViewer {
3    open(): void;
4    close(): void;
5}
6
7// Implementazioni concrete per ogni tipo di visualizzatore
8class TextViewer implements DocumentViewer {
9    open() { console.log("Opening a text document..."); }
10    close() { console.log("Closing the text document."); }
11}
12
13class SpreadsheetViewer implements DocumentViewer {
14    open() { console.log("Opening a spreadsheet document..."); }
15    close() { console.log("Closing the spreadsheet document."); }
16}
17
18class PresentationViewer implements DocumentViewer {
19    open() { console.log("Opening a presentation document..."); }
20    close() { console.log("Closing the presentation document."); }
21}
22
23// Factory generica per creare il visualizzatore
24class ViewerFactory {
25    static createViewer<T extends DocumentViewer>(ViewerClass: new () => T): T {
26        return new ViewerClass();
27    }
28}
29
30// Utilizzo della factory generica
31const textViewer = ViewerFactory.createViewer(TextViewer);
32textViewer.open(); // Output: Opening a text document...
33textViewer.close(); // Output: Closing the text document.
34
35const spreadsheetViewer = ViewerFactory.createViewer(SpreadsheetViewer);
36spreadsheetViewer.open(); // Output: Opening a spreadsheet document...
37spreadsheetViewer.close(); // Output: Closing the spreadsheet document.
38
39const presentationViewer = ViewerFactory.createViewer(PresentationViewer);
40presentationViewer.open(); // Output: Opening a presentation document...
41presentationViewer.close(); // Output: Closing the presentation document.

Decorator Pattern con Decoratori Sperimentali

I Decoratori forniscono un modo per aggiungere annotazioni e una sintassi di metaprogrammazione per dichiarazioni di classe e membri. Possiamo usarli per modificare il comportamento di classi, metodi, accessori, proprietà e parametri senza modificare il codice originale.

Quando si usa questo pattern?

Immagina di avere un'applicazione con un sistema di autenticazione. Con il tempo, c'è la necessità di aggiungere funzionalità di logging per monitorare gli accessi degli utenti, inclusi successi e fallimenti dell'autenticazione. Modificare le classi esistenti non è l'opzione migliore, perché vorresti mantenere la separazione delle responsabilità e minimizzare le modifiche al codice esistente.

Utilizzando il Decorator Pattern, puoi creare un decoratore che aggiunge funzionalità di logging alle operazioni di autenticazione esistenti senza modificare il codice originale. Questo permette di aggiungere dinamicamente nuove funzionalità in modo flessibile.

1function LogMethod(target: any, propertyName: string, descriptor: PropertyDescriptor) {
2    const originalMethod = descriptor.value;
3    descriptor.value = function(...args: any[]) {
4        console.log(`Logging before executing ${propertyName}`);
5        const result = originalMethod.apply(this, args);
6        console.log(`Logging after executing ${propertyName}`);
7        return result;
8    }
9}
10
11class AuthenticationService {
12    @LogMethod
13    login(username: string, password: string): boolean {
14        // Logica di autenticazione
15        console.log(`Authenticating ${username}`);
16        // Qui andrebbe il codice reale per l'autenticazione
17        return true; // Simula un successo dell'autenticazione
18    }
19}
20
21const authService = new AuthenticationService();
22authService.login('user1', 'password123');

Questo approccio è particolarmente utile quando è necessario aggiungere nuove funzionalità, come il logging, a classi esistenti in modo non invasivo. Il Decorator Pattern consente di mantenere le classi originali non modificate e di rispettare il principio di singola responsabilità, aggiungendo funzionalità tramite composizione piuttosto che ereditarietà. È utile anche per aggiungere funzionalità in modo condizionale o solo in certi contesti, come il logging dettagliato in ambienti di sviluppo o testing, senza influenzare la produzione.

I decoratori in TypeScript sono molto potenti e offrono un modo elegante per aggiungere funzionalità a classi e metodi. Tuttavia, come suggerisce il nome, sono ancora sperimentali e il loro comportamento potrebbe cambiare in future versioni del linguaggio. Prima di utilizzarli in un progetto, è importante considerare la stabilità delle API e la compatibilità con futuri aggiornamenti di TypeScript. Inoltre, l'uso dei decoratori può influenzare la leggibilità del codice e l'esperienza di debugging, quindi è importante usarli con giudizio e in scenari appropriati. Se vuoi optare per il decorator pattern, ma senza i decoratori sperimentali di TypeScript puoi convertire il codice come segue:

1interface AuthenticationService {
2    login(username: string, password: string): boolean;
3}
4
5// Implementazione esistente dell'autenticazione
6class ConcreteAuthenticationService implements AuthenticationService {
7    login(username: string, password: string): boolean {
8        // Logica di autenticazione (semplificata per l'esempio)
9        console.log(`Authenticating ${username}`);
10        return true; // Supponiamo che l'autenticazione abbia successo
11    }
12}
13
14// Decoratore che aggiunge logging
15class LoggingDecorator implements AuthenticationService {
16    private service: AuthenticationService;
17
18    constructor(service: AuthenticationService) {
19        this.service = service;
20    }
21
22    login(username: string, password: string): boolean {
23        console.log(`Logging attempt for ${username}`);
24        const result = this.service.login(username, password);
25        if (result) {
26            console.log(`Login successful for ${username}`);
27        } else {
28            console.log(`Login failed for ${username}`);
29        }
30        return result;
31    }
32}
33
34// Utilizzo del decoratore
35const authService = new ConcreteAuthenticationService();
36const loggedAuthService = new LoggingDecorator(authService);
37
38loggedAuthService.login('user1', 'password123'); // Aggiunge logging all'operazione di login

Singleton Pattern con Classi Statiche

Il Singleton Pattern assicura che una classe abbia solo un'istanza e fornisce un punto di accesso globale a quella istanza. In TypeScript, possiamo implementare il Singleton Pattern utilizzando una proprietà statica.

Quando si usa questo pattern?

Le applicazioni spesso necessitano di accedere a configurazioni globali sparse in vari punti del codice. Queste configurazioni possono includere impostazioni come stringhe di connessione al database, configurazioni dell'interfaccia utente, o parametri di configurazione di API esterne. È importante che queste configurazioni siano consistenti attraverso l'intera applicazione e che esista un unico punto di verità per queste impostazioni per evitare inconsistenze e errori.

Utilizzando il Singleton Pattern, possiamo creare una classe di configurazione che espone un unico punto di accesso globale alle impostazioni di configurazione. Questo assicura che tutte le parti dell'applicazione accedano alla stessa istanza delle configurazioni, mantenendo la consistenza.

1class AppConfig {
2    private static instance: AppConfig;
3    private settings: { [key: string]: string | number } = {};
4
5    private constructor() {}
6
7    public static getInstance(): AppConfig {
8        if (!AppConfig.instance) {
9            AppConfig.instance = new AppConfig();
10        }
11        return AppConfig.instance;
12    }
13
14    public setSetting(key: string, value: string | number): void {
15        this.settings[key] = value;
16    }
17
18    public getSetting(key: string): string | number {
19        return this.settings[key];
20    }
21}
22
23// Utilizzo del Singleton per la configurazione
24const config = AppConfig.getInstance();
25config.setSetting('apiUrl', 'https://api.example.com');
26config.setSetting('retryAttempts', 5);
27
28// Accesso alla stessa istanza da un'altra parte dell'applicazione
29const sameConfig = AppConfig.getInstance();
30console.log(sameConfig.getSetting('apiUrl')); // Output: https://api.example.com
31console.log(sameConfig.getSetting('retryAttempts')); // Output: 5

Questo approccio è particolarmente utile in applicazioni grandi e complesse, dove le configurazioni devono essere accessibili in modo uniforme in tutto il codice. Utilizzando il Singleton Pattern per le configurazioni, l'applicazione può facilmente adattarsi a cambiamenti, come il passaggio da un ambiente di test a uno di produzione, semplicemente cambiando i valori in un unico punto senza dover cercare e sostituire valori sparsi per tutto il codice.

In questo scenario, il Singleton Pattern offre una soluzione elegante per garantire che tutti i componenti dell'applicazione abbiano accesso allo stesso set di configurazioni, evitando duplicazioni e mantenendo il codice pulito e organizzato. Questo pattern è particolarmente utile per mantenere la configurabilità e l'estensibilità di un'applicazione nel tempo.

Considerazioni sui pattern avanzati

L'uso di pattern avanzati in TypeScript può migliorare notevolmente la qualità del codice, rendendolo più leggibile, manutenibile e meno propenso a errori. Tuttavia, è importante usare questi pattern con discernimento, poiché un loro uso eccessivo o inappropriato può portare a codice complicato e difficile da comprendere. La chiave è trovare il giusto equilibrio tra l'uso di tipi avanzati e pattern di progettazione per risolvere problemi specifici, mantenendo il codice semplice e leggibile.

In conclusione, TypeScript offre un potente insieme di strumenti per migliorare la progettazione e l'implementazione del software. Esplorando e applicando i pattern avanzati di tipizzazione e progettazione, gli sviluppatori possono sfruttare al meglio le caratteristiche del linguaggio per creare applicazioni robuste, scalabili e facili da mantenere.

Caricamento...

Diventiamo amici di penna? ✒️

Iscriviti alla newsletter per ricevere una mail ogni paio di settimane con le ultime novità, gli ultimi approfondimenti e gli ultimi corsi gratuiti puubblicati. Ogni tanto potrei scrivere qualcosa di commerciale, ma solo se mi autorizzi, altrimenti non ti disturberò oltre.

Se non ti piace la newsletter ti ricordo la community su Discord, dove puoi chiedere aiuto, fare domande e condividere le tue esperienze (ma soprattutto scambiare due meme con me). Ti aspetto!

Ho in previsione di mandarti una newsletter ogni due settimane e una commerciale quando capita.