Pompei Tech
Guide · typescript

Il tuo primo programma TypeScript

Il tuo primo programma TypeScript

Immagina di dover far tradurre un contratto legale a un traduttore professionista. Prima di consegnarglielo, quel traduttore ti farà sempre una domanda: "per chi è questo testo?". Se il destinatario è un tribunale internazionale, servirà un linguaggio formale e tecnico. Se invece è un cliente straniero che deve solo capire i punti essenziali, andrà bene una versione semplificata. Il contenuto informativo del testo originale resta lo stesso, ma la "versione finale" cambia completamente in base a chi la deve leggere.

Il compilatore di TypeScript funziona in modo molto simile: prende il tuo codice, che contiene sia la logica che le informazioni sui tipi, e lo "traduce" in JavaScript puro, adattando l'output al "pubblico" a cui è destinato — che può essere un vecchio browser, l'ultima versione di Node.js, o qualsiasi altro ambiente di esecuzione tu abbia scelto. In questo capitolo vediamo come funziona in pratica questo processo di traduzione.

Anatomia di un progetto TypeScript

Il più piccolo progetto TypeScript sensato è composto da tre file:

sh
package.json   # Il manifesto del pacchetto npm
tsconfig.json  # La configurazione del compilatore TypeScript
src/index.ts   # "il programma" vero e proprio

Creiamolo da zero. Apri il terminale, crea una cartella e inizializzala come progetto npm:

sh
mkdir saluto-typescript
cd saluto-typescript
npm init -y
npm install --save-dev typescript

package.json — il nostro manifesto minimo:

jsonc
{
  "name": "saluto-typescript",
  "license": "UNLICENSED",
  "devDependencies": {
    "typescript": "^5.6.0"
  },
  "scripts": {
    "dev": "tsc --watch --preserveWatchOutput"
  }
}

Da notare:

  • Abbiamo una sola dipendenza: typescript.
  • Abbiamo uno script dev, che esegue il compilatore in modalità "watch" (tiene d'occhio i file sorgenti e ricompila automaticamente a ogni modifica).

Ecco praticamente il file di configurazione più semplice possibile per il compilatore:

tsconfig.json:

jsonc
{
  "compilerOptions": {
    "outDir": "dist", // dove mettere i file compilati
    "target": "ES2015", // il "livello" del linguaggio JS di destinazione
    "moduleResolution": "Node" // come trovare i moduli CommonJS in node_modules
  },
  "include": ["src"] // quali file compilare
}

Tutte queste opzioni potrebbero essere specificate da riga di comando (per esempio tsc --outDir dist), ma man mano che un progetto cresce in complessità, avere questo file di configurazione centralizzato è un enorme vantaggio.

Infine, scriviamo un programma TypeScript relativamente semplice, ma con qualche caratteristica interessante che renderà più evidenti gli effetti delle modifiche alla proprietà target nel nostro tsconfig.json:

  • L'uso del costruttore nativo Promise (introdotto in ES2015).
  • L'uso di async e await (introdotti in ES2017).
  • L'uso di un campo static privato di classe (introdotto in ES2022).

src/index.ts:

ts
/**
 * Crea una promise che si risolve dopo un certo tempo
 * @param n numero di millisecondi prima che la promise si risolva
 */
function timeout(n: number) {
  return new Promise((res) => setTimeout(res, n));
}

/**
 * Somma due numeri
 * @param a primo numero
 * @param b secondo numero
 */
export async function addNumbers(a: number, b: number) {
  await timeout(500);
  return a + b;
}

class Foo {
  static #bar = 3;
  static getValue() {
    return Foo.#bar;
  }
}

//== Esegui il programma ==//
(async () => {
  console.log(await addNumbers(Foo.getValue(), 4));
})();

Se apri questo file in Visual Studio Code e passi il mouse sopra un simbolo — per esempio il nome della funzione addNumbers — vedrai un tooltip con la sua firma completa. Questo è uno degli strumenti più importanti per imparare come TypeScript "capisce" il tuo codice: usalo spesso, anche solo per curiosità, mentre segui questi capitoli.

Eseguire il compilatore

Dalla cartella del progetto, puoi lanciare:

sh
npx tsc

per compilare il progetto una volta sola. In alternativa, sempre dalla stessa cartella, puoi avviare il task che ricompila automaticamente ogni volta che modifichi un file importante:

sh
npm run dev

Dovresti vedere qualcosa del genere nel terminale:

log
12:01:57 - Starting compilation in watch mode...

Nota che, all'interno della cartella saluto-typescript:

  • È comparsa una cartella ./dist.
  • Al suo interno c'è un file index.js.

Apri quel file: sarà un po' disordinato. Ecco un estratto di cosa contiene, compilato con target: "ES2015":

js
var __awaiter =
  (this && this.__awaiter) ||
  function (thisArg, _arguments, P, generator) {
    function adopt(value) {
      return value instanceof P
        ? value
        : new P(function (resolve) {
            resolve(value);
          });
    }
    return new (P || (P = Promise))(function (resolve, reject) {
      function fulfilled(value) {
        try {
          step(generator.next(value));
        } catch (e) {
          reject(e);
        }
      }
      function rejected(value) {
        try {
          step(generator["throw"](value));
        } catch (e) {
          reject(e);
        }
      }
      function step(result) {
        result.done
          ? resolve(result.value)
          : adopt(result.value).then(fulfilled, rejected);
      }
      step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
  };
// ...segue codice equivalente ma "srotolato" a mano, incluso un helper
// per simulare i campi privati delle classi (#bar), non supportati
// nativamente da ES2015.

Se conosci il vecchio standard ES5, noterai alcune cose che ci dicono che questo output punta a ES2015. Per esempio, l'uso della keyword yield, una funzione generatrice function*, le class e il costruttore Promise.

Cambiare il target del linguaggio

Se andiamo in tsconfig.json e modifichiamo la proprietà compilerOptions.target:

diff
{
    "compilerOptions": {
        "outDir": "dist",
-       "target": "ES2015"
+       "target": "ES2017"
    },
    "include": ["src"]
}

e ricompiliamo, il file dist/index.js sarà molto più pulito. Cosa è cambiato?

  • Iniziamo a vedere async e await direttamente nell'output.
  • L'helper __awaiter non c'è più.

Il codice comincia a somigliare molto di più all'originale: come se le informazioni sui tipi fossero semplicemente state rimosse dal file .ts di partenza. Rimane ancora qualche "trucco" del compilatore per simulare il campo privato di classe, che non è supportato nativamente in JavaScript fino a ES2022.

Proviamo, infine, con ES2022:

diff
{
    "compilerOptions": {
        "outDir": "dist",
-       "target": "ES2017"
+       "target": "ES2022"
    },
    "include": ["src"]
}

Ricompilando, vedrai che anche il campo privato #bar viene emesso nativamente, senza alcun trucco aggiuntivo: a questo target, Node.js e i browser moderni supportano questa sintassi in modo nativo.

I file di dichiarazione

Se abiliti l'opzione declaration: true nel tsconfig.json, noterai che viene generato anche un file .d.ts come parte del processo di compilazione. Questo si chiama file di dichiarazione, e contiene esclusivamente le informazioni sui tipi, senza alcuna implementazione:

ts
/**
 * Somma due numeri
 * @param a primo numero
 * @param b secondo numero
 */
export declare function addNumbers(
  a: number,
  b: number,
): Promise<number>;

Un buon modo per pensare ai diversi tipi di file:

  • I file .ts contengono sia le informazioni sui tipi che il codice che viene effettivamente eseguito.
  • I file .js contengono solo il codice che viene eseguito.
  • I file .d.ts contengono solo le informazioni sui tipi.

Esistono altre estensioni che hanno un equivalente TypeScript:

Scopo del fileEstensione JSEstensione TS
React.jsx.tsx
Moduli ES nativi.mjs.mts

Tipi di moduli

CommonJS

Hai notato che la keyword export era ancora presente nell'output compilato del nostro programma? Di default stiamo generando moduli ES2015 a partire dal sorgente TypeScript.

Se provassimo a eseguire questo file con node così com'è:

sh
node dist/index.js

otterremmo un errore:

sh
export async function addNumbers(a, b) {
^^^^^^
SyntaxError: Unexpected token 'export'

Sembra che, almeno con le versioni più recenti di Node.js e con il modo in cui il nostro progetto è attualmente configurato, non possiamo eseguire direttamente questo programma così com'è.

Per convenzione Node.js si aspetta moduli CommonJS, quindi dobbiamo dire a TypeScript di generare questo tipo di output. Aggiungiamo una nuova proprietà al file tsconfig.json:

diff
"compilerOptions": {
    "outDir": "dist",
+   "module": "CommonJS",

Ricompila e riguarda il file dist/index.js: noterai che il modo in cui la funzione addNumbers viene esportata è cambiato:

js
exports.addNumbers = addNumbers;

Questo è un segnale che stiamo generando moduli CommonJS! Possiamo ora provare a eseguire il programma con node:

sh
node dist/index.js

Se tutto funziona correttamente, dovresti vedere il programma fermarsi per un breve istante e poi stampare 7 in console, prima di terminare con successo.

Moduli ES

Node.js oggi supporta anche l'esecuzione diretta di moduli nativi (file .mjs)! Possiamo configurare TypeScript per generare questo tipo di output. Modifica così il tuo tsconfig.json:

diff
   "target": "ES2022",
+  "module": "NodeNext",
-  "module": "CommonJS",
-  "moduleResolution": "Node"

Ricompila il progetto lanciando npx tsc dalla cartella saluto-typescript, poi esegui:

sh
node dist/index.js

Dovresti vedere di nuovo 7 stampato in console!

Complimenti, hai appena compilato ed eseguito il tuo primo programma TypeScript, osservando come cambia l'output a seconda del target del linguaggio e del sistema di moduli scelto.