Da JSON a tipi TypeScript

Incolla la risposta di un'API o un file .json: escono le interface (o i type) di TypeScript e lo JSON Schema 2020-12, con i campi facoltativi dedotti da tutti gli elementi di una lista, non solo dal primo. Il JSON non esce dal tuo browser.

Oppure tocca qui o trascina un file .json
Il JSON viene letto e trasformato nel tuo browser: non viene inviato da nessuna parte, quindi puoi incollare anche una risposta con dati veri.

Perché non basta guardare il primo elemento

Il JSON che arriva da un'API quasi mai ha tutti gli oggetti uguali. In una lista di ordini il primo ha le note e il secondo no; il terzo ha il tracking perché è già partito. Un generatore che guarda solo il primo elemento scrive un tipo con le note obbligatorie e senza il tracking: il codice compila, e poi a metà esecuzione trovi undefined dove il tipo prometteva un testo.

Qui si leggono tutti gli elementi di ogni lista, anche delle liste dentro le liste. Una chiave che manca anche in un solo oggetto diventa facoltativa (tracking?: string). Un campo che vale ora un numero ora un testo diventa number | string, e la pagina lo segnala con quante volte compare ciascun tipo, perché spesso è un dato sporco: un prezzo scritto una volta fra virgolette.

Mancare e valere null non sono la stessa cosa, e il tipo lo dice. Una chiave che a volte non c'è prende il punto interrogativo; una chiave che c'è sempre ma a volte vale null prende | null; se succedono tutte e due, le prende entrambe, e con --strict TypeScript tratta i due casi in modo diverso.

Le scelte che trovi nel risultato

Una lista sempre vuota diventa unknown[] e non any[]. Dei suoi elementi non si sa niente, e unknown ti obbliga a controllarli prima di usarli, mentre any spegnerebbe il controllo proprio dove serve. Un oggetto sempre vuoto diventa Record<string, unknown> per lo stesso motivo.

Le chiavi che non sono nomi validi restano fra virgolette: "data-nascita", "first name", "2fa". Le parole riservate come default o class, invece, come nomi di proprietà sono ammesse e restano senza virgolette.

I nomi delle interfacce annidate vengono dalla chiave: indirizzo_spedizione diventa IndirizzoSpedizione. Per gli elementi di una lista il nome va al singolare, un'euristica dichiarata che guarda come finisce la parola in italiano, in inglese e in spagnolo (utenti dà Utente, categories dà Category, direcciones dà Direccion). Le desinenze che possono voler dire due cose le lascia stare: da -umi vengono sia volume sia consumo, quindi legumi resta Legumi, mentre le parole più comuni sono scritte una per una (consumi dà Consumo). Quello che la desinenza non può sciogliere è la stessa parola in due lingue: roles è role in inglese e rol in spagnolo, e vince l'inglese. Per questo ogni nome messo al singolare è elencato sotto il risultato.

Due oggetti con la stessa forma diventano un tipo solo: se l'indirizzo di spedizione e quello di fatturazione hanno le stesse chiavi esce Indirizzo, usato due volte. Se i nomi non hanno niente in comune, come author e category che sono tutti e due un id e un nome, il secondo resta come alias, type Category = Author, perché category: Author si leggerebbe sbagliato. E un nome che coincide con un tipo che TypeScript o il browser hanno già (Date, Event, Location, AudioData) prende davanti il nome del genitore, per non fare ombra a quello vero in tutto il file.

interface o type: quale scegliere

Per descrivere dei dati le due forme si equivalgono: scegli quella che usa già il tuo progetto. Le interface si possono estendere e sono quelle che la documentazione di TypeScript consiglia per gli oggetti; i type servono quando il tipo non è un oggetto, quindi una radice che è una lista o un valore semplice esce come type anche se hai scelto interface, per esempio export type Radice = RadiceElemento[];, e la pagina lo dice. Tutto esce con export.

JSON Schema, per controllare i dati quando arrivano

I tipi di TypeScript spariscono quando il codice gira: proteggono chi scrive il programma, non il programma dai dati che riceve. Per controllare una risposta quando arriva serve uno schema, e la seconda uscita è un JSON Schema draft 2020-12, che leggono Ajv in JavaScript, jsonschema in Python e quasi tutti gli strumenti che validano configurazioni e API.

Lo schema rispecchia i tipi: le stesse interfacce stanno in $defs, i campi obbligatori in required, i null e i campi misti in un type con più valori. Distingue integer da number dal modo in cui il numero è scritto: 10 è un intero, 10.0 no, perché chi scrive il punto sta dicendo che lì possono arrivare decimali.

La casella dello schema chiuso aggiunge additionalProperties: false: una chiave che nell'esempio non c'era diventa un errore. Accendila per i dati che produci tu; lasciala spenta per l'API di qualcun altro, che domani può aggiungere un campo e farti rifiutare risposte buone.

Numeri troppo grandi e chiavi ripetute

In JavaScript un numero intero è esatto solo fino a 9.007.199.254.740.991, cioè 2 alla 53 meno 1. Oltre, le ultime cifre cambiano da sole: 9007199254740993 letto con JSON.parse diventa 9007199254740992, senza dire niente. Succede con gli identificativi, ed è per questo che certe API mandano l'id anche come testo. Questa pagina legge il JSON con un lettore che tiene le cifre originali, e ti dice dove sta un numero così e che cosa diventa. Nel tipo resta number; il consiglio è farlo arrivare come stringa.

Stessa cosa per le chiavi ripetute nello stesso oggetto: JSON.parse tiene l'ultima senza avvisare, e qui vengono segnalate con la riga. E un JSON rotto riceve riga, colonna e il motivo in parole: la virgola di troppo prima di una graffa, gli apici singoli, un commento, un True scritto alla maniera di Python.

Come è stato provato

Un generatore di tipi può sbagliare in un modo che non si vede: tipi tutti facoltativi, o tutti any, compilano sempre. Per questo la prova prende centinaia di JSON generati a caso (alcuni con la stessa forma ripetuta in più punti, ognuno con una sua variante) e fa compilare al compilatore di TypeScript, con --strict, i tipi prodotti insieme al JSON stesso. Poi toglie un campo obbligatorio, cambia il tipo di un valore o aggiunge una chiave inventata, e pretende che il compilatore rifiuti il file.

Lo schema lo controlla una libreria che non è nostra, jsonschema di Python: il JSON di partenza deve essere valido, la versione rotta no. Il lettore del JSON è confrontato con quello di Python su migliaia di testi storpiati apposta e dà le stesse risposte, con due differenze volute: tollera il BOM invisibile che certi programmi di Windows mettono in testa al file, e rifiuta NaN e Infinity, che in JSON non esistono.

Quello che non fa

Non indovina i significati. Una data resta string, un'email pure: dal testo non si può sapere se domani arriverà un formato diverso. Così lo stato di un ordine, con tre valori visti, resta string e non diventa l'elenco di quei tre: i valori ammessi li scrivi tu.

Non riconosce i dizionari. Un oggetto che usa gli identificativi come chiavi diventa un'interfaccia con quelle chiavi; se in realtà è una mappa, cambialo in Record<string, Utente>. E non inventa tipi ricorsivi: un albero diventa interfacce annidate profonde quanto l'esempio.

Non scrive codice di validazione né classi, e non ripara un JSON rotto: per quello c'è Ripara JSON, e per reimpaginarlo e vedere dove si rompe c'è Formatta e valida JSON. Se da un JSON ti serve una tabella c'è JSON in CSV, e se il file è YAML, come un docker-compose o un manifest di Kubernetes, prima passa da Da YAML a JSON.