JavaScript: caratteristiche moderne

JavaScript ha subito una trasformazione radicale a partire dall'introduzione di ECMAScript 2015 (ES6) e con le successive edizioni annuali dello standard. Da linguaggio spesso criticato per le sue incongruenze e limitazioni, si è evoluto in uno strumento potente, espressivo e versatile, capace di affrontare lo sviluppo di applicazioni complesse sia lato client che lato server. In questo articolo analizzeremo in dettaglio le caratteristiche moderne che definiscono il JavaScript contemporaneo.

Dichiarazione delle variabili con let e const

Prima di ES6, l'unico modo per dichiarare una variabile era utilizzare var, che presentava problemi legati allo scope di funzione e all'hoisting. Le parole chiave let e const risolvono questi problemi introducendo il block scoping.

let consente di dichiarare variabili il cui valore può essere riassegnato, ma la cui visibilità è limitata al blocco in cui vengono definite. const, invece, impedisce la riassegnazione del binding, pur non rendendo immutabile il valore referenziato nel caso di oggetti e array.

// Scope di blocco con let
for (let i = 0; i < 5; i++) {
    // 'i' è visibile solo all'interno di questo blocco
    console.log(i);
}
// console.log(i); // ReferenceError: i non è definita

// const impedisce la riassegnazione
const API_URL = "https://api.example.com";
// API_URL = "altro"; // TypeError: assegnamento a variabile costante

// Ma gli oggetti dichiarati con const restano mutabili
const settings = { theme: "dark" };
settings.theme = "light"; // Consentito: si modifica la proprietà, non il binding

La regola generale nel JavaScript moderno è preferire const come scelta predefinita e ricorrere a let solo quando la riassegnazione è effettivamente necessaria. L'uso di var è ormai considerato una pratica obsoleta.

Arrow function

Le arrow function offrono una sintassi più concisa per definire funzioni e, soprattutto, risolvono uno dei problemi storici di JavaScript: il binding lessicale di this. A differenza delle funzioni tradizionali, le arrow function non creano un proprio contesto this, ma ereditano quello dell'ambito circostante.

// Sintassi concisa per espressioni singole
const double = (number) => number * 2;

// Con corpo di blocco per logica più complessa
const processItems = (items) => {
    const filtered = items.filter((item) => item.active);
    return filtered.map((item) => item.name);
};

// Binding lessicale di this
class Timer {
    constructor() {
        this.seconds = 0;
    }

    start() {
        // 'this' si riferisce all'istanza di Timer grazie alla arrow function
        setInterval(() => {
            this.seconds++;
            console.log(this.seconds);
        }, 1000);
    }
}

Le arrow function non possono essere usate come costruttori, non dispongono dell'oggetto arguments e non sono adatte come metodi di un oggetto letterale quando si ha bisogno di un this dinamico.

Template literal

I template literal, delimitati da backtick, permettono di creare stringhe multilinea e di interpolare espressioni JavaScript al loro interno tramite la sintassi ${espressione}. Questa funzionalità elimina la necessità di concatenazioni manuali e rende il codice decisamente più leggibile.

const userName = "Marco";
const userAge = 28;

// Interpolazione di espressioni
const greeting = `Benvenuto, ${userName}. Hai ${userAge} anni.`;

// Stringhe multilinea senza caratteri di escape
const htmlFragment = `
    <ul>
        <li>${userName}</li>
        <li>Età: ${userAge}</li>
    </ul>
`;

// Tagged template per elaborazione personalizzata
function highlight(strings, ...values) {
    return strings.reduce((result, str, i) => {
        const value = values[i] !== undefined ? `<strong>${values[i]}</strong>` : "";
        return result + str + value;
    }, "");
}

const output = highlight`L'utente ${userName} ha ${userAge} anni.`;

Destructuring

Il destructuring consente di estrarre valori da array e proprietà da oggetti in modo sintetico, assegnandoli direttamente a variabili. Si tratta di una delle funzionalità più utilizzate nel JavaScript moderno, specialmente in combinazione con i parametri delle funzioni.

// Destructuring di oggetti
const user = { name: "Laura", role: "admin", age: 34 };
const { name, role, age } = user;

// Con rinomina e valori predefiniti
const { name: fullName, country = "IT" } = user;

// Destructuring di array
const coordinates = [45.42, 12.33];
const [latitude, longitude] = coordinates;

// Scambio di variabili senza variabile temporanea
let first = 1;
let second = 2;
[first, second] = [second, first];

// Nei parametri delle funzioni
function createUser({ name, role = "viewer", active = true }) {
    return { name, role, active };
}

const newUser = createUser({ name: "Paolo" });

Il destructuring supporta anche strutture annidate e il rest pattern, che permette di raccogliere le proprietà rimanenti in un nuovo oggetto o gli elementi rimanenti in un nuovo array.

// Destructuring annidato
const response = {
    data: {
        users: [{ id: 1, name: "Anna" }]
    },
    status: 200
};

const { data: { users: [firstUser] } } = response;

// Rest pattern con oggetti
const { name: userName2, ...remainingProps } = user;
// remainingProps contiene { role: "admin", age: 34 }

Spread operator

L'operatore spread (...) espande un iterabile nei suoi singoli elementi. È fondamentale per la creazione di copie superficiali, la composizione di oggetti e la manipolazione immutabile dei dati.

// Copia superficiale di array
const original = [1, 2, 3];
const copy = [...original];

// Unione di array
const combined = [...original, 4, 5, ...copy];

// Copia e modifica di oggetti (pattern immutabile)
const defaultConfig = { theme: "light", language: "it", debug: false };
const userConfig = { theme: "dark", debug: true };
const finalConfig = { ...defaultConfig, ...userConfig };
// Risultato: { theme: "dark", language: "it", debug: true }

// Conversione di argomenti con rest parameters
function sum(...numbers) {
    return numbers.reduce((total, n) => total + n, 0);
}

console.log(sum(1, 2, 3, 4)); // 10

Programmazione asincrona: Promise, async/await

La gestione dell'asincronicità è sempre stata un aspetto cruciale di JavaScript. Le Promise, introdotte in ES6, hanno fornito un modello strutturato per rappresentare il completamento o il fallimento di operazioni asincrone, sostituendo il pattern dei callback annidati noto come "callback hell".

// Creazione di una Promise
function fetchUserData(userId) {
    return new Promise((resolve, reject) => {
        // Simulazione di una richiesta asincrona
        setTimeout(() => {
            if (userId > 0) {
                resolve({ id: userId, name: "Utente " + userId });
            } else {
                reject(new Error("ID utente non valido"));
            }
        }, 1000);
    });
}

// Concatenazione di Promise
fetchUserData(1)
    .then((user) => {
        console.log(user.name);
        return fetchUserData(2);
    })
    .then((user) => console.log(user.name))
    .catch((error) => console.error(error.message));

Con ES2017, la sintassi async/await ha ulteriormente semplificato la gestione del codice asincrono, consentendo di scrivere operazioni asincrone con una struttura sintattica sequenziale, pur mantenendo la natura non bloccante dell'esecuzione.

// async/await per un flusso leggibile
async function loadDashboard() {
    try {
        // Le richieste vengono eseguite in sequenza
        const user = await fetchUserData(1);
        const posts = await fetch(`/api/posts?userId=${user.id}`);
        const data = await posts.json();

        console.log(`${user.name} ha ${data.length} post.`);
        return data;
    } catch (error) {
        // Gestione centralizzata degli errori
        console.error("Errore nel caricamento:", error.message);
        throw error;
    }
}

// Esecuzione parallela con Promise.all
async function loadAllUsers(userIds) {
    // Tutte le richieste partono contemporaneamente
    const promises = userIds.map((id) => fetchUserData(id));
    const users = await Promise.all(promises);
    return users;
}

// Promise.allSettled per gestire sia successi che fallimenti
async function loadWithFallback(userIds) {
    const results = await Promise.allSettled(
        userIds.map((id) => fetchUserData(id))
    );

    const successful = results
        .filter((r) => r.status === "fulfilled")
        .map((r) => r.value);

    const failed = results
        .filter((r) => r.status === "rejected")
        .map((r) => r.reason);

    return { successful, failed };
}

Moduli ES

Il sistema di moduli nativo di JavaScript (import/export) ha standardizzato l'organizzazione del codice in unità indipendenti e riutilizzabili, eliminando la dipendenza da soluzioni esterne come CommonJS o AMD.

// mathUtils.js - Esportazioni nominali
export function add(a, b) {
    return a + b;
}

export function multiply(a, b) {
    return a * b;
}

export const PI = 3.141592653589793;

// logger.js - Esportazione predefinita
export default class Logger {
    constructor(prefix) {
        this.prefix = prefix;
    }

    log(message) {
        console.log(`[${this.prefix}] ${message}`);
    }
}

// app.js - Importazioni
import Logger from "./logger.js";
import { add, multiply, PI } from "./mathUtils.js";

// Importazione con alias
import { add as sum } from "./mathUtils.js";

// Importazione dinamica per il caricamento condizionale
async function loadModule(moduleName) {
    const module = await import(`./modules/${moduleName}.js`);
    return module.default;
}

L'importazione dinamica con import() restituisce una Promise e consente il caricamento on-demand dei moduli, una tecnica essenziale per ottimizzare le prestazioni delle applicazioni web attraverso il code splitting.

Classi

La sintassi delle classi in JavaScript fornisce un'astrazione chiara sopra il sistema prototipale, rendendo più espliciti concetti come ereditarietà, incapsulamento e polimorfismo.

class EventEmitter {
    // Campi privati (ES2022)
    #listeners = new Map();

    on(eventName, callback) {
        if (!this.#listeners.has(eventName)) {
            this.#listeners.set(eventName, []);
        }
        this.#listeners.get(eventName).push(callback);
        return this; // Concatenazione fluente
    }

    emit(eventName, ...args) {
        const callbacks = this.#listeners.get(eventName) || [];
        callbacks.forEach((callback) => callback(...args));
    }

    // Metodo statico
    static create() {
        return new EventEmitter();
    }
}

// Ereditarietà
class TypedEmitter extends EventEmitter {
    #allowedEvents;

    constructor(allowedEvents) {
        super();
        this.#allowedEvents = new Set(allowedEvents);
    }

    on(eventName, callback) {
        if (!this.#allowedEvents.has(eventName)) {
            throw new Error(`Evento "${eventName}" non consentito`);
        }
        return super.on(eventName, callback);
    }
}

const emitter = new TypedEmitter(["click", "submit"]);
emitter.on("click", (data) => console.log("Click:", data));
emitter.emit("click", { x: 100, y: 200 });

I campi privati, indicati dal prefisso #, garantiscono un vero incapsulamento a livello di linguaggio, a differenza della precedente convenzione basata sull'underscore che era puramente nominale.

Iteratori e generatori

Il protocollo iterabile definisce un'interfaccia standard per attraversare collezioni di dati. I generatori, dichiarati con la sintassi function*, semplificano la creazione di iteratori personalizzati e consentono di produrre sequenze di valori in modo lazy, generando ciascun valore solo quando richiesto.

// Generatore per sequenza di Fibonacci
function* fibonacci() {
    let previous = 0;
    let current = 1;

    while (true) {
        yield current;
        // Calcolo del prossimo valore nella sequenza
        [previous, current] = [current, previous + current];
    }
}

// Consumo lazy: si generano solo i valori necessari
const sequence = fibonacci();
for (let i = 0; i < 10; i++) {
    console.log(sequence.next().value);
}

// Iteratore personalizzato su un oggetto
class Range {
    constructor(start, end, step = 1) {
        this.start = start;
        this.end = end;
        this.step = step;
    }

    // Implementazione del protocollo iterabile
    [Symbol.iterator]() {
        let current = this.start;
        const end = this.end;
        const step = this.step;

        return {
            next() {
                if (current <= end) {
                    const value = current;
                    current += step;
                    return { value, done: false };
                }
                return { done: true };
            }
        };
    }
}

// Utilizzo con for...of e spread
const range = new Range(1, 10, 2);
for (const number of range) {
    console.log(number); // 1, 3, 5, 7, 9
}

const allValues = [...new Range(0, 5)]; // [0, 1, 2, 3, 4, 5]

I generatori asincroni (async function*) combinano i vantaggi dei generatori con la programmazione asincrona, permettendo di iterare su flussi di dati che arrivano nel tempo, come eventi da un server o righe di un file di grandi dimensioni.

// Generatore asincrono per paginazione
async function* fetchPages(baseUrl) {
    let page = 1;
    let hasMore = true;

    while (hasMore) {
        const response = await fetch(`${baseUrl}?page=${page}`);
        const data = await response.json();

        yield data.items;

        hasMore = data.hasNextPage;
        page++;
    }
}

// Consumo con for await...of
async function processAllPages() {
    for await (const items of fetchPages("/api/products")) {
        // Ogni pagina viene elaborata man mano che arriva
        items.forEach((item) => console.log(item.name));
    }
}

Optional chaining e nullish coalescing

L'optional chaining (?.) e il nullish coalescing (??) sono due operatori introdotti in ES2020 che semplificano enormemente la gestione dei valori nulli o indefiniti, un problema ricorrente nella programmazione JavaScript.

const apiResponse = {
    data: {
        user: {
            profile: {
                address: null
            }
        }
    }
};

// Optional chaining: accesso sicuro a proprietà annidate
const city = apiResponse.data?.user?.profile?.address?.city;
// Restituisce undefined senza generare errori

// Su chiamate di metodo
const length = apiResponse.data?.user?.profile?.getName?.();

// Su accesso a indici di array
const firstItem = apiResponse.data?.items?.[0];

// Nullish coalescing: valore predefinito solo per null/undefined
const pageSize = apiResponse.data?.pageSize ?? 25;
// A differenza di ||, non considera 0 o "" come valori falsy

// Combinazione dei due operatori
const displayName = apiResponse.data?.user?.profile?.displayName
    ?? apiResponse.data?.user?.name
    ?? "Utente anonimo";

// Assegnazione con nullish coalescing (ES2021)
let userSettings = null;
userSettings ??= { theme: "light", language: "it" };
// Assegna solo se userSettings è null o undefined

Strutture dati moderne: Map, Set, WeakMap, WeakRef

JavaScript moderno offre strutture dati specializzate che superano le limitazioni degli oggetti e degli array tradizionali utilizzati come contenitori generici.

// Map: chiavi di qualsiasi tipo, ordine di inserimento garantito
const cache = new Map();
const requestKey = { url: "/api/data", method: "GET" };

cache.set(requestKey, { data: [1, 2, 3], timestamp: Date.now() });
cache.set("config", { timeout: 5000 });

console.log(cache.size); // 2
console.log(cache.has(requestKey)); // true

// Iterazione sulle coppie chiave-valore
for (const [key, value] of cache) {
    console.log(key, value);
}

// Set: collezione di valori unici
const uniqueTags = new Set(["javascript", "web", "javascript", "node"]);
console.log(uniqueTags.size); // 3 - i duplicati vengono eliminati

// Operazioni insiemistiche (ES2025)
const frontendTags = new Set(["react", "css", "javascript"]);
const backendTags = new Set(["node", "express", "javascript"]);

const commonTags = frontendTags.intersection(backendTags);
const allTags = frontendTags.union(backendTags);
const onlyFrontend = frontendTags.difference(backendTags);

// WeakMap: chiavi con riferimento debole, utile per metadati
const metadata = new WeakMap();

function processElement(element) {
    // I dati vengono rimossi automaticamente dal garbage collector
    // quando 'element' non è più referenziato altrove
    if (!metadata.has(element)) {
        metadata.set(element, { processedAt: Date.now() });
    }
    return metadata.get(element);
}

// WeakRef: riferimento debole a un oggetto (ES2021)
class ObjectPool {
    #references = [];

    add(object) {
        this.#references.push(new WeakRef(object));
    }

    getAlive() {
        // Restituisce solo gli oggetti ancora in memoria
        return this.#references
            .map((ref) => ref.deref())
            .filter((obj) => obj !== undefined);
    }
}

Proxy e Reflect

L'oggetto Proxy consente di intercettare e ridefinire le operazioni fondamentali su un oggetto target, come la lettura e la scrittura di proprietà, le invocazioni di funzione e la costruzione. Reflect fornisce i metodi corrispondenti per eseguire le operazioni predefinite in modo controllato.

// Proxy per validazione automatica
function createValidatedObject(schema) {
    return new Proxy({}, {
        set(target, property, value) {
            const validator = schema[property];
            if (validator && !validator(value)) {
                throw new TypeError(
                    `Valore non valido per "${property}": ${value}`
                );
            }
            return Reflect.set(target, property, value);
        },
        get(target, property) {
            if (!(property in target)) {
                console.warn(`Accesso a proprietà inesistente: "${property}"`);
            }
            return Reflect.get(target, property);
        }
    });
}

const user = createValidatedObject({
    age: (value) => typeof value === "number" && value >= 0 && value <= 150,
    email: (value) => typeof value === "string" && value.includes("@")
});

user.age = 30;       // Consentito
user.email = "a@b.c"; // Consentito
// user.age = -5;     // TypeError: valore non valido

// Proxy per oggetto reattivo (pattern alla base dei framework moderni)
function reactive(target, onChange) {
    return new Proxy(target, {
        set(obj, property, value) {
            const oldValue = obj[property];
            const result = Reflect.set(obj, property, value);
            if (oldValue !== value) {
                // Notifica del cambiamento
                onChange(property, value, oldValue);
            }
            return result;
        }
    });
}

const state = reactive({ count: 0 }, (prop, newVal, oldVal) => {
    console.log(`${prop}: ${oldVal} → ${newVal}`);
});

state.count = 1; // "count: 0 → 1"

Gestione avanzata degli errori

Il JavaScript moderno ha introdotto miglioramenti nella gestione degli errori, tra cui la possibilità di creare errori con una causa concatenata tramite la proprietà cause e nuovi tipi di errore aggregati.

// Errori con causa (ES2022)
async function fetchUserProfile(userId) {
    try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
            throw new Error(`Risposta HTTP ${response.status}`);
        }
        return await response.json();
    } catch (error) {
        // La causa originale viene preservata nella catena degli errori
        throw new Error("Impossibile caricare il profilo utente", {
            cause: error
        });
    }
}

// Classi di errore personalizzate
class ValidationError extends Error {
    constructor(field, message) {
        super(message);
        this.name = "ValidationError";
        this.field = field;
    }
}

class HttpError extends Error {
    constructor(statusCode, message) {
        super(message);
        this.name = "HttpError";
        this.statusCode = statusCode;
    }
}

// AggregateError per errori multipli
async function validateForm(formData) {
    const errors = [];

    if (!formData.name) {
        errors.push(new ValidationError("name", "Il nome è obbligatorio"));
    }
    if (!formData.email?.includes("@")) {
        errors.push(new ValidationError("email", "Email non valida"));
    }

    if (errors.length > 0) {
        throw new AggregateError(errors, "Validazione del form fallita");
    }

    return true;
}

Metodi moderni per array e oggetti

Le edizioni recenti di ECMAScript hanno arricchito le API di Array e Object con metodi che favoriscono l'immutabilità e la leggibilità del codice.

// Metodi immutabili sugli array (ES2023)
const numbers = [3, 1, 4, 1, 5, 9];

// toSorted restituisce un nuovo array ordinato senza modificare l'originale
const sorted = numbers.toSorted((a, b) => a - b);

// toReversed restituisce un nuovo array invertito
const reversed = numbers.toReversed();

// toSpliced restituisce un nuovo array con le modifiche applicate
const modified = numbers.toSpliced(2, 1, 99);

// with restituisce una copia con l'elemento sostituito all'indice specificato
const updated = numbers.with(0, 42);

// L'array originale resta intatto
console.log(numbers); // [3, 1, 4, 1, 5, 9]

// Array.from e Array.fromAsync
const nodeList = document.querySelectorAll("p");
const paragraphs = Array.from(nodeList, (node) => node.textContent);

// findLast e findLastIndex (ES2023)
const transactions = [
    { id: 1, type: "credit", amount: 100 },
    { id: 2, type: "debit", amount: 50 },
    { id: 3, type: "credit", amount: 200 }
];

const lastCredit = transactions.findLast((t) => t.type === "credit");
// { id: 3, type: "credit", amount: 200 }

// Object.groupBy (ES2024)
const grouped = Object.groupBy(transactions, (t) => t.type);
// { credit: [{...}, {...}], debit: [{...}] }

// structuredClone per copie profonde
const original = {
    nested: { data: [1, 2, 3] },
    date: new Date()
};

const deepCopy = structuredClone(original);
deepCopy.nested.data.push(4);
console.log(original.nested.data.length); // 3 - l'originale non è modificato

Pattern matching con l'operatore using

La dichiarazione using, introdotta in ES2024, implementa il pattern di gestione esplicita delle risorse (Explicit Resource Management). Le risorse dichiarate con using vengono automaticamente rilasciate al termine del blocco in cui sono definite, richiamando il metodo [Symbol.dispose]().

// Definizione di una risorsa con dispose
class DatabaseConnection {
    #connection;

    constructor(connectionString) {
        this.#connection = openConnection(connectionString);
        console.log("Connessione aperta");
    }

    query(sql) {
        return this.#connection.execute(sql);
    }

    // Metodo chiamato automaticamente alla fine del blocco
    [Symbol.dispose]() {
        this.#connection.close();
        console.log("Connessione chiusa");
    }
}

// La connessione viene chiusa automaticamente alla fine del blocco
{
    using db = new DatabaseConnection("postgresql://localhost/app");
    const results = db.query("SELECT * FROM users");
    console.log(results);
} // [Symbol.dispose]() viene invocato qui

// Versione asincrona con await using
class FileHandle {
    async [Symbol.asyncDispose]() {
        await this.flush();
        await this.close();
        console.log("File chiuso");
    }
}

async function processFile() {
    await using handle = new FileHandle("data.txt");
    await handle.write("contenuto");
} // La risorsa viene rilasciata in modo asincrono

Lavorare con le date: Temporal API

La Temporal API rappresenta la soluzione nativa di JavaScript per la gestione delle date e degli orari, progettata per superare le numerose limitazioni dell'oggetto Date. Temporal fornisce tipi immutabili, supporto completo per i fusi orari e un'API coerente e prevedibile.

// PlainDate: solo data, senza orario né fuso orario
const today = Temporal.Now.plainDateISO();
const birthday = Temporal.PlainDate.from("1990-05-15");

// Calcolo della differenza tra date
const age = birthday.until(today, { largestUnit: "year" });
console.log(`Età: ${age.years} anni`);

// PlainDateTime: data e ora senza fuso orario
const appointment = Temporal.PlainDateTime.from("2025-03-20T14:30:00");

// ZonedDateTime: data, ora e fuso orario completo
const meetingRome = Temporal.ZonedDateTime.from({
    year: 2025,
    month: 6,
    day: 15,
    hour: 10,
    minute: 0,
    timeZone: "Europe/Rome"
});

// Conversione tra fusi orari
const meetingNewYork = meetingRome.withTimeZone("America/New_York");
console.log(meetingNewYork.toString());

// Durate e aritmetica temporale
const duration = Temporal.Duration.from({ hours: 2, minutes: 30 });
const endTime = appointment.add(duration);

// Confronto tra istanti temporali
const now = Temporal.Now.instant();
const deadline = Temporal.Instant.from("2025-12-31T23:59:59Z");
const remaining = now.until(deadline, { largestUnit: "day" });
console.log(`Giorni rimanenti: ${remaining.days}`);

Conclusione

Il JavaScript moderno è un linguaggio profondamente diverso da quello che molti sviluppatori hanno conosciuto nei suoi primi decenni di vita. Le caratteristiche analizzate in questo articolo, dal destructuring ai generatori asincroni, dai Proxy alla Temporal API, dimostrano come il linguaggio si sia evoluto per rispondere alle esigenze dello sviluppo software contemporaneo. La chiave per scrivere codice JavaScript di qualità oggi risiede nella conoscenza approfondita di queste funzionalità e nella capacità di combinarle in modo efficace, privilegiando sempre la leggibilità, l'immutabilità e la robustezza nella gestione degli errori.