package.json: ogni campo spiegato
Guida al package.json campo per campo: name, version, type, exports, scripts, dependencies e peerDependencies, engines, e cosa fanno davvero i prefissi ^ e ~.
Ogni progetto JavaScript ne ha uno, lo apri dieci volte al giorno e per il 90% del tempo ci tocchi solo la sezione scripts. Poi un giorno un errore dice "Cannot use import statement outside a module", o una libreria si lamenta di una peer dependency, e ti accorgi che quel file decide molte più cose di quanto pensavi. In questo articolo trovi ogni campo spiegato per quello che fa davvero, il versionamento semantico con la differenza precisa tra ^ e ~, e gli errori che fanno perdere ore.
Cos'è
Il package.json è il file che descrive il tuo progetto a Node e al gestore di pacchetti: come si chiama, cosa gli serve per funzionare, come si avvia e come va interpretato il codice che contiene.
È un file JSON normale, quindi niente commenti e niente virgole finali: due errori di sintassi che bloccano tutto e che non danno messaggi particolarmente chiari.
{
"name": "mio-progetto",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "vitest run"
},
"dependencies": {
"react": "^19.0.0"
},
"devDependencies": {
"vite": "^6.0.0"
},
"engines": {
"node": ">=20.11.0"
}
}
I campi di identità
name e version
name è l'identificativo del pacchetto. Conta davvero solo se lo pubblichi su npm — lì deve essere unico nel registro — ma anche in un'applicazione privata viene usato come chiave nei workspace di un monorepo.
Regole: minuscolo, senza spazi. Può avere un ambito, cioè un prefisso con la chiocciola: @miaazienda/design-system, che evita le collisioni di nome e permette di pubblicare pacchetti privati.
version deve seguire il versionamento semantico, tre numeri separati da punti. Su un'applicazione privata nessuno la guarda, ma se pubblichi è il contratto con chi ti installa.
private
"private": true
Una riga che vale la pena mettere in ogni applicazione. Impedisce a npm publish di pubblicare il progetto per sbaglio. Se il codice contiene chiavi o logiche di business, è l'unica cosa che ti separa da una pubblicazione accidentale che poi non puoi davvero annullare.
type: il campo che rompe le build
Qui si concentra una quantità sproporzionata di confusione. JavaScript ha due sistemi di moduli: CommonJS (require) ed ES Modules (import). Il campo type dice a Node come interpretare i file .js del tuo progetto.
"type": "commonjs"o campo assente — i.jssono CommonJS, si usarequire()."type": "module"— i.jssono ES Modules, si usaimport.
// Con "type": "module"
import fs from "node:fs";
// Con "type": "commonjs" (o senza il campo)
const fs = require("node:fs");
Sbagliare produce due messaggi che vedrai prima o poi: Cannot use import statement outside a module (stai usando import senza aver dichiarato "type": "module") e require is not defined in ES module scope (l'opposto).
La via d'uscita per i casi singoli sono le estensioni esplicite: un file .mjs è sempre un modulo ES, un file .cjs è sempre CommonJS, a prescindere dal campo type. Serve quando hai un file di configurazione che uno strumento pretende in un formato e il resto del progetto è nell'altro.
Un dettaglio che fa impazzire: con "type": "module" gli import relativi devono includere l'estensione. import { x } from "./utils" non funziona, serve "./utils.js". Con TypeScript si scrive comunque .js anche se il file sorgente è .ts, cosa che a prima vista sembra un errore e non lo è.
main ed exports
Servono solo se il pacchetto viene importato da altro codice, cioè se è una libreria.
main è il campo storico: indica il file da caricare quando qualcuno scrive import x from "mio-pacchetto".
exports è quello moderno: può fornire file diversi a seconda del sistema di moduli, e soprattutto blocca l'accesso a tutto ciò che non hai dichiarato:
{
"main": "./dist/index.cjs",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./utils": "./dist/utils.js"
}
}
Con questo, import x from "mio-pacchetto/dist/interno.js" fallisce: non è nella mappa. È un bene, perché definisce una superficie pubblica invece di lasciare che chiunque dipenda dai tuoi file interni. Ed è anche la causa degli errori "package subpath is not defined" quando aggiorni una libreria che ha appena adottato exports.
L'ordine dentro exports conta: types va sempre per primo, altrimenti TypeScript non trova le definizioni.
scripts, e il fatto che passano per la shell
"scripts": {
"dev": "next dev",
"build": "next build",
"lint": "eslint .",
"test": "vitest run",
"prepare": "husky"
}
Li lanci con npm run nome. Tre cose che non sono ovvie.
Il primo: npm run esegue il comando nella shell del sistema. Il che significa che rm -rf dist && next build funziona sul tuo Mac e fallisce su Windows, dove rm non esiste. È il motivo per cui esistono pacchetti come rimraf o cross-env: servono solo a rendere gli script uguali ovunque.
Il secondo: node_modules/.bin finisce nel PATH. Per questo "dev": "vite" funziona senza che vite sia installato globalmente. Lo stesso comando scritto a mano nel terminale non funziona, e non è un mistero: è che npm run aggiunge quella cartella.
Il terzo: alcuni nomi sono speciali. prepare gira dopo ogni npm install, preX e postX prima e dopo lo script X. E npm start e npm test si lanciano senza run, gli altri no.
Per passare argomenti serve il doppio trattino: npm run test -- --watch.
dependencies, devDependencies, peerDependencies
La distinzione che genera più dubbi.
dependencies — ciò che serve al codice per girare. Se lo importi in un file che finisce in produzione, va qui.
devDependencies — ciò che serve solo per lavorare o per costruire: bundler, compilatore TypeScript, linter, framework di test. Quando qualcuno installa il tuo pacchetto, queste non vengono scaricate.
Su un'applicazione la distinzione conta meno di quanto si creda, perché in produzione spedisci il risultato della build. Conta invece parecchio in Docker, dove npm ci --omit=dev ti fa un'immagine molto più piccola, e sempre su una libreria pubblicata.
peerDependencies — qui serve un esempio concreto, perché la definizione astratta non aiuta.
Immagina di scrivere un plugin per React: una libreria di componenti. Il tuo codice usa useState, quindi React ti serve. Ma se lo metti in dependencies, ogni progetto che ti installa si ritrova due copie di React: la sua e la tua. E due copie di React significano hook che esplodono con errori incomprensibili, perché lo stato vive in una copia e i componenti girano nell'altra.
La soluzione è dire "React mi serve, ma me lo deve fornire chi mi usa":
{
"name": "mia-libreria-componenti",
"peerDependencies": {
"react": ">=18.0.0 <20.0.0"
},
"peerDependenciesMeta": {
"react": { "optional": false }
},
"devDependencies": {
"react": "^19.0.0"
}
}
Nota il doppio: peerDependencies per dichiarare il vincolo, devDependencies per avere React quando sviluppi e testi la libreria.
La regola pratica: se la tua libreria si aggancia a un framework ospite e deve condividerne l'istanza — React, Vue, un plugin per ESLint, un adattatore per Vite — quella cosa va in peerDependencies. In tutti gli altri casi no.
npm installa da solo le peer mancanti: comodo, ma se i vincoli sono incompatibili l'installazione fallisce con un muro di testo, e --legacy-peer-deps lo mette a tacere senza risolverlo.
engines
"engines": {
"node": ">=20.11.0",
"pnpm": ">=9"
}
Dichiara le versioni su cui il progetto è pensato per girare. Con npm è solo un avvertimento, a meno che tu non aggiunga engine-strict=true in un file .npmrc; pnpm invece lo fa rispettare per impostazione predefinita.
Vale la pena metterlo comunque: molte piattaforme di hosting lo leggono per scegliere la versione di Node in build, ed è il posto giusto dove documentare un requisito che altrimenti resta nella testa di chi ha impostato il progetto.
Il versionamento semantico, e i prefissi
Una versione 19.3.1 si legge così:
| Parte | Nome | Cambia quando |
|---|---|---|
| 19 | major | ci sono modifiche incompatibili |
| 3 | minor | si aggiungono funzionalità compatibili |
| 1 | patch | si correggono bug, senza cambiare l'interfaccia |
I prefissi nel package.json dicono quanto sei disposto ad aggiornare in automatico:
| Notazione | Significa | Con 19.3.1 accetta |
|---|---|---|
^19.3.1 | aggiorna minor e patch | da 19.3.1 a 19.9.9, mai 20.x |
~19.3.1 | aggiorna solo patch | da 19.3.1 a 19.3.9, mai 19.4.0 |
19.3.1 | versione esatta | solo 19.3.1 |
>=19.0.0 | qualsiasi cosa da lì in su | anche la 25, cambi incompatibili inclusi |
* o latest | l'ultima disponibile | tutto, ed è da evitare |
^ è il predefinito di npm install ed è la scelta giusta nella maggior parte dei casi: prendi correzioni di sicurezza e miglioramenti senza rischiare rotture, a patto che chi mantiene il pacchetto rispetti le regole.
Ed è qui la sottigliezza che rovina i pomeriggi. Su un pacchetto non maturo, ^ è pericoloso. Due motivi:
- Sotto la 1.0 le regole cambiano. Per la specifica,
^0.5.2accetta solo 0.5.x, non 0.6.0 — perché nelle versioni zero il secondo numero fa da major. Ma molti autori non lo sanno e pubblicano rotture in una patch, tipo0.5.3. Con^te la prendi in casa senza accorgertene. - Il confine tra minor e patch è un'interpretazione. Chi mantiene un pacchetto giovane, magari da solo, spesso considera "compatibile" un cambio che per te non lo è. Un valore predefinito diverso, un campo rinominato, un comportamento affinato: tecnicamente minor, praticamente la tua build rossa.
La difesa pratica: fissa la versione esatta sulle dipendenze giovani o critiche — poco seguito, sotto la 1.0, senza changelog — e lascia ^ su React, Express e compagnia, dove la disciplina sul versionamento è seria. E ricorda che quello che ti salva davvero è il lockfile: il package.json esprime un desiderio, il lock registra il fatto.
Errori comuni
- Mettere in
devDependenciesqualcosa che serve a runtime. In locale funziona tutto perchénode_modulesè completo. In produzione, con--omit=dev, ottieniCannot find moduleal primo avvio. - Modificare a mano le versioni senza reinstallare. Il lock resta al vecchio valore e i due file divergono:
npm cifallirà. - Virgole finali e commenti. Il JSON non li ammette. Se ti serve una nota, usa un campo inventato come
"//": "nota". --legacy-peer-depscome abitudine. Supera un conflitto oggi e nasconde un'incompatibilità che salterà fuori in produzione.- Script che funzionano solo sul tuo sistema operativo. Se nel team c'è una macchina Windows, i comandi Unix sono una segnalazione garantita.
- Dimenticare
private: truesu un'applicazione aziendale. Unnpm publishdistratto e il codice è pubblico.
In sintesi
Il package.json è la carta d'identità del progetto: cosa è, cosa gli serve, come si avvia.
I campi che causano più problemi sono due: type, che decide se i tuoi .js sono moduli ES o CommonJS ed è dietro metà degli errori di import; e le peerDependencies, che servono quando la tua libreria deve usare la stessa istanza del framework ospite invece di portarsene una copia.
^ aggiorna minor e patch, ~ solo le patch. Entrambi si fidano di chi pubblica: su pacchetti giovani o sotto la 1.0 quella fiducia è mal riposta, e lì conviene fissare la versione esatta.
dependencies è ciò che serve per girare, devDependencies ciò che serve per costruire. La distinzione pesa soprattutto in Docker e nelle librerie pubblicate.
Per capire perché il package.json da solo non basta a rendere riproducibile un progetto, leggi cos'è il lockfile. Per la scelta dello strumento che lo legge, vedi npm, yarn o pnpm.