Tipi Guerrilla in TypeScript
Design tipografico ribelle
Guerrilla Types in TypeScript
In questo articolo esploreremo tre tecniche intriganti (forse terribili?) per aiutare nella progettazione dei tipi!
L’obiettivo principale è avere interfacce coerenti e prevedibili per Modelli/Entità/Classi.
- Approcci alla Progettazione dei Tipi
- Tecnica #1: Perché non tutti
- Tecnica #2: Mix‑ins
- Tecnica #3: Organizzare con Namespace
- Riepilogo
Approcci alla Progettazione dei Tipi
Probabilmente hai già incontrato o scritto diversi schemi attorno alle “implementazioni di tipo”, soprattutto quando consumi dati da API di terze parti.
Nota: Ignoro intenzionalmente i processi “tradizionali” di costruzione di diagrammi Entity‑Relationship (ERD) o di gerarchie di ereditarietà della programmazione orientata agli oggetti (OOP). Qui stiamo creando tipi per rappresentare dati API semi‑strutturati.
Esaminiamo due approcci di alto livello: un singolo oggetto grande (top‑down) contro più tipi nominati (bottom‑up).
Un singolo oggetto grande
Priorità all’esplicità rispetto a riusabilità e DRY‑ness.
Bonus: l’esperienza IDE/Sviluppatore è eccellente, poiché i tooltip mostrano un’anteprima più completa – senza complicazioni.
interface ProductDetails { name: string; seller: { name: string }; availability: Array<{ warehouseId: string; quantity: number }>; reviews: Array<{ authorId: number; stars: number }>;}Poiché diamo priorità a una leggibilità esplicita, è accettabile indulgere in un po’ di ripetizione (con moderazione). Quando gruppi di proprietà si ripetono molte volte, sentitevi liberi di estrarre i campi ripetuti in un tipo nominato.
Tipi nominati multipli
Priorità alla riusabilità e al DRY.
Questo approccio è probabilmente quello preferito di gran lunga.
interface ProductDetails { name: string; seller: Seller; reviews: Reviews[]; availability: Availability[];}interface Seller { name: string; }interface Availability { warehouseId: string; quantity: number; }interface Reviews { authorId: number; stars: number; }Nel complesso, questo approccio è ottimo. Ma non è privo di svantaggi.
- La leggibilità è eccellente all’inizio; tuttavia, può peggiorare man mano che la dimensione e il numero dei tipi aumentano.
- DRY in modo spietato, ma a che prezzo? (Ne parleremo più avanti.)
- L’esperienza dello sviluppatore può risentirne perché i tooltip forniscono meno informazioni.
⚠️ Da (circa) TypeScript v3, il Language Server tronca i tooltip, omettendo le proprietà annidate.
💡 Esistono dei trucchi per migliorare un po’ la situazione. Prova a tenere premutoCmdoCtrle poi passa il mouse sopra vari nomi di tipo: dovresti vedere almeno un livello aggiuntivo di proprietà nel tooltip.
Perché dobbiamo scegliere tra questi due approcci? (Tipo “big picture” vs. sottotipi nominati.)
Tecnica #1: Perché non tutti
Possiamo avere tutto?
- Chiarezza dei tipi “big‑picture”?
- Più i sottotipi nominati?
- Senza duplicazione?
✅ SÌ! 🎉
export interface ProductDetails { name: string; seller: { name: string }; reviews: Array<{ authorId: number; stars: number }>; availability: Array<{ warehouseId: string; quantity: number }>;}export type Seller = ProductDetails["seller"];export type Review = ProductDetails["reviews"][number];export type Availability = ProductDetails["availability"][number];- Crea tipi strutturati “Primari” di grandi dimensioni.
- Esporta i sotto‑tipi derivati dal tipo Primario.
Questo approccio brilla davvero nei sistemi in cui gli oggetti “di alto livello” traggono vantaggio da una documentazione centralizzata. Inoltre, la tecnica favorisce il riuso tra molteplici casi d’uso: Modelli, Servizi, Risultati di Query, ecc.
Tecnica #2: Mix‑ins
Questa strategia consiste nel combinare i campi giusti, con i nomi giusti, per rappresentare oggetti logici singoli. L’obiettivo è soddisfare efficientemente più scenari usando le Utility Types di TypeScript e le Unioni di Tipo.
Il metodo si discosta dall’eredità OOP tradizionale e dalle gerarchie, che mirano a creare strati di oggetti in tassonomie strettamente legate. L’approccio mix‑in riguarda tipi piatti e poco correlati, raggruppando campi affini e riducendo la duplicazione.
Esempi di Mix‑in
interface TodoModel { text: string; complete: boolean;}interface InstanceMixin { id: number;}/** TodoDraft rappresenta lo stato del Form, possibilmente con tutti i campi undefined */export type TodoDraft = Partial<TodoModel>;/** Todo rappresenta un record di istanza Todo proveniente dal database */export type Todo = TodoModel & InstanceMixin;Esempio User
interface User { id: number; name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}Rappresentiamo l’User prima e dopo il salvataggio nel database.
// Campi core dell'utente (ad esempio per un <form>)interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}// Campi provenienti dal databaseinterface InstanceMixin { id: number; createdAt: Date; updatedAt: Date;}// Un'**istanza** di User - con tutti i campitype UserInstance = InstanceMixin & UserBase;Ora possiamo scolpire esattamente i campi di cui abbiamo bisogno (ad esempio password per create/update, ma non incluso nelle query di UserInstance).
interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}interface InstanceMixin { id: number; createdAt: Date; updatedAt: Date;}/** Payload User per la registrazione, includendo il campo `password` */export type UserPayload = UserBase & { password: string };/** Rappresenta il tipo User restituito dal server. */export type UserInstance = UserBase & InstanceMixin;- “È una buona pratica?”
- “Dovrei provarla?”
Nessuna risposta definitiva. Proseguiamo!
Tecnica #3: Organizzare con i Namespace
Qui dichiariamo uno spazio dei nomi ModelMixins. Questo fornisce una certa organizzazione e un modello di riuso più chiaro.
Forme standardizzate
createdAteupdatedAtsono sempre presenti insieme.id, nonIDo_id.
// `src/types/mixins.d.ts`namespace ModelMixins { interface Identity { id: number; } interface Timestamp { createdAt: Date; updatedAt: Date; } type Instance = ModelMixins.Identity & ModelMixins.Timestamp; interface HashedPassword { passwordHash: string; } interface InputPassword { password: string; }}Uso delle Unioni di Tipo
// `src/types/user.d.ts`export interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}// Tipo `User` unico, usando una Unione di Tipo per rappresentare// dinamicamente gli stati pre‑ e post‑creazione.export type User = | (UserBase & ModelMixins.Instance & ModelMixins.HashedPassword) | (UserBase & ModelMixins.InputPassword);Se lo desideri, puoi anche esportare i singoli tipi nominati:
/** Payload dell'utente per la registrazione, includendo il campo `password` */export type UserPayload = UserBase & ModelMixins.Instance & ModelMixins.HashedPassword;/** Rappresenta il tipo User restituito dal server. */export type UserInstance = UserBase & ModelMixins.InputPassword;Uso reale
Ecco una funzione upsert() che utilizza l’operatore in per distinguere tra i tipi UserInstance e UserPayload.
function upsert(user: User) { if ("id" in user) { // TypeScript sa che `user` qui ha i campi dell'Instance (id, createdAt, ecc.) return updateUser(user.id, user); } else { // TypeScript sa che questo deve essere la versione `UserBase & ModelMixins.InputPassword` dell'utente. return createUser(user); }}Riepilogo
Abbiamo esaminato tre tecniche e alcune idee di supporto correlate.
Ti starai chiedendo se questi siano buoni pattern. Dovrei adottare alcune di queste idee?
Risorse
- Suggerimenti TypeScript per progetti legacy: tipa solo ciò che serve
- Il nuovo eccellente libro di Matt Pocock
- Suggerimenti Total TypeScript