TypeScript avanzato con un esempio reale (parte 2)
Pubblicato: 2020-11-20È ora di continuare (e, si spera, finire) il nostro tutorial TypeScript. Se ti sei perso i post precedenti che abbiamo scritto su TypeScript, eccoli qui: la nostra introduzione iniziale a TypeScript e la prima parte di questo tutorial in cui spiego l'esempio JavaScript con cui stiamo lavorando e i passaggi che abbiamo fatto per migliorarlo parzialmente .
Oggi finiremo il nostro esempio completando tutto ciò che ancora manca. In particolare, vedremo prima come creare tipi che sono versioni parziali di altri tipi esistenti. Vedremo quindi come digitare correttamente le azioni di un negozio Redux utilizzando le unioni di tipo e discuteremo i vantaggi offerti dalle unioni di tipo. E, infine, ti mostrerò come creare una funzione polimorfica il cui tipo restituito dipende dai suoi argomenti.
Una breve rassegna di ciò che abbiamo fatto finora...
Nella prima parte del tutorial abbiamo utilizzato (parte di) un negozio Redux che abbiamo preso da Nelio Content come esempio di lavoro. Tutto è iniziato come un semplice codice JavaScript che doveva essere migliorato aggiungendo tipi concreti che lo rendessero più robusto e intelligibile. Così, ad esempio, abbiamo definito le seguenti tipologie:
type PostId = number; type Day = string; type Post = { id: PostId; title: string; author: string; day: Day; status: string; isSticky: boolean; }; type State = { posts: Dictionary<PostId, Post>; days: Dictionary<Day, PostId[]>; }; che ci ha aiutato a capire, a colpo d'occhio, il tipo di informazioni con cui lavora il nostro negozio. In questo caso particolare, ad esempio, possiamo vedere che lo stato della nostra applicazione memorizza due cose: un elenco di posts (che abbiamo indicizzato tramite il loro PostId ) e una struttura chiamata days che, dato un certo giorno, restituisce un elenco di identificatori di posta. Possiamo anche vedere gli attributi (e i loro tipi specifici) che troveremo in un oggetto Post .
Una volta definiti questi tipi, abbiamo modificato tutte le funzioni del nostro esempio per utilizzarli. Questa semplice attività ha trasformato le firme delle funzioni opache di JavaScript:
// Selectors function getPost( state, id ) { ... } function getPostsInDay( state, day ) { ... } // Actions function receiveNewPost( post ) { ... } function updatePost( postId, attributes ) { ... } // Reducer function reducer( state, action ) { ... }alle firme di funzione TypeScript autoesplicative:
// Selectors function getPost( state: State, id: PostId ): Post | undefined { ... } function getPostsInDay( state: State, day: Day ): PostId[] { ... } // Actions function receiveNewPost( post: Post ): any { ... } function updatePost( postId: PostId, attributes: any ): any { ... } // Reducer function reducer( state: State, action: any ): State { ... } La funzione getPostsInDay è un ottimo esempio di quanto TypeScript migliorerà la qualità del tuo codice. Se guardi la controparte JavaScript, non sai davvero cosa restituirà quella funzione. Certo, il suo nome potrebbe suggerire il tipo di risultato (è un elenco di post, forse?), ma devi guardare il codice sorgente della funzione (e probabilmente anche le azioni e i riduttori) per essere sicuro (in realtà è un elenco di ID posta). Si può migliorare questa situazione nominando meglio le cose ( getIdsOfPostsInDay , per esempio), ma non c'è niente come tipi concreti per pulire ogni dubbio: PostId[] .
Quindi, ora che sei al passo con lo stato attuale delle cose, è tempo di andare avanti e sistemare tutto ciò che abbiamo saltato la scorsa settimana. In particolare, sappiamo che dobbiamo digitare gli attributi degli attributes della funzione updatePost e dobbiamo definire i tipi che avranno le nostre azioni (notare che in reducer , l'attributo action in questo momento è di tipo any ).
Come digitare un oggetto i cui attributi sono un sottoinsieme di un altro oggetto
Riscaldiamoci iniziando con qualcosa di semplice. La funzione updatePost genera un'azione che segnala la nostra intenzione di aggiornare determinati attributi di un determinato ID post. Ecco come appare:
function updatePost( postId: PostId, attributes: any ): any { return { type: 'UPDATE_POST', postId, attributes, }; }ed ecco come l'azione viene utilizzata dal riduttore per aggiornare il post nel nostro negozio:
function reducer( state: State, action: any ): State { // ... switch ( action.type ) { // ... case 'UPDATE_POST': if ( ! state.posts[ action.postId ] ) { return state; } const post = { ...state.posts[ action.postId ], ...action.attributes, }; return { ... }; } // ... }Come puoi vedere, il riduttore cerca il post nel negozio e, se c'è, aggiorna i suoi attributi sovrascrivendoli utilizzando quelli inclusi nell'azione.
Ma quali sono esattamente gli attributes di un'azione? Bene, sono chiaramente qualcosa che assomiglia a un Post , poiché dovrebbero sovrascrivere gli attributi che potremmo trovare in un post:
type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Post; };ma se proviamo ad usarlo vedremo che non funziona:
const post: Post = { id: 1, title: 'Title', author: 'Ruth', day: '2020-10-01', status: 'draft', isSticky: false, }; const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; perché non vogliamo che gli attributes siano un Post stesso; vogliamo che sia un sottoinsieme di attributi Post (cioè vogliamo specificare solo quegli attributi di un oggetto Post che andremo a sovrascrivere).
Per risolvere questo problema, usa il tipo di utilità Partial :
type UpdatePostAction = { type: 'UPDATE_POST'; postId: number; attributes: Partial<Post>; };E questo è tutto! O è?
Filtraggio esplicito degli attributi
Il frammento di codice precedente è ancora difettoso, poiché è possibile ottenere alcuni errori di runtime che il compilatore di TypeScript non sta verificando. Ecco perché: l'azione che segnala un post update ha due argomenti, un post ID e l'insieme di attributi che vogliamo aggiornare. Una volta che abbiamo l'azione pronta, il riduttore si occupa di sovrascrivere il post esistente con i nuovi valori:
const post = { ...state.posts[ action.postId ], ...action.attributes, }; E questa è precisamente la parte difettosa del nostro codice; è possibile che l'attributo postId dell'azione abbia un ID pots x e l'attributo id negli attributes abbia un ID post diverso y :
const action: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 2, author: 'Toni', }, }; Questa è ovviamente un'azione valida, quindi TypeScript non attiva alcun errore, ma sappiamo che non dovrebbe esserlo. L'attributo id negli attributes (se presente) e l'attributo postId dovrebbero avere lo stesso valore, altrimenti abbiamo un'azione incoerente. Il nostro tipo di azione è impreciso perché ci consente di definire una situazione che dovrebbe essere impossibile... quindi come possiamo risolverlo? Abbastanza facilmente: basta cambiare questo tipo in modo che questo scenario che dovrebbe essere impossibile diventi effettivamente impossibile.
La prima soluzione a cui ho pensato è la seguente: rimuovere l'attributo postId dall'azione e aggiungere l'ID attributes :
type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post>; }; function updatePost( postId: PostId, attributes: Partial<Post> ): UpdatePostAction { return { type: 'UPDATE_POST', attributes: { ...attributes, id: postId, }, }; } Quindi, aggiorna il tuo riduttore in modo che utilizzi action.attributes.id invece di action.postId per trovare e sovrascrivere il post esistente.
Sfortunatamente, questa soluzione non è l'ideale, perché attributes è un "post parziale", ricordi? Ciò significa che, in teoria, l'attributo id può essere o meno nell'oggetto attributes . Certo, sappiamo che ci sarà, perché siamo noi a generare l'azione... ma i nostri tipi sono ancora imprecisi. Se in futuro qualcuno modificasse la funzione updatePost e non si assicurasse che gli attributes includano il postId , l'azione risultante sarebbe valida secondo TypeScript ma il nostro codice non funzionerebbe:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };Quindi, se vogliamo che TypeScript ci protegga, dobbiamo essere il più precisi possibile quando specifichiamo i tipi e assicurarci che rendano impossibili gli stati impossibili. Considerando tutto questo, abbiamo solo due opzioni disponibili:
- Se abbiamo un attributo
postIdin azione (come abbiamo fatto all'inizio), l'oggettoattributesnon deve contenere un attributoid. - Se, invece, l'azione non ha un attributo
postId, allora gliattributesdevono contenere un attributoid.
La prima soluzione può essere facilmente specificata utilizzando un altro tipo di utilità, Omit , che ci consente di creare un nuovo tipo rimuovendo gli attributi da un tipo esistente:
type UpdatePostAction = { type: 'UPDATE_POST'; postId: PostId, attributes: Partial< Omit<Post, 'id'> >; };che funziona come previsto:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', postId: 1, attributes: { id: 1, author: 'Toni', }, }; Per la seconda opzione, dobbiamo aggiungere esplicitamente l'attributo id sopra il tipo Partial<Post> che abbiamo definito:

type UpdatePostAction = { type: 'UPDATE_POST'; attributes: Partial<Post> & { id: PostId }; };che, ancora, ci dà il risultato atteso:
const workingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { id: 1, author: 'Toni', }, }; const failingAction: UpdatePostAction = { type: 'UPDATE_POST', attributes: { author: 'Toni', }, };Tipi di unione
Nella sezione precedente, abbiamo già visto come digitare una delle due azioni del nostro negozio. Facciamo lo stesso con la seconda azione. Sapendo che receiveNewPost assomiglia a questo:
function receiveNewPost( post: Post ): any { return { type: 'RECEIVE_NEW_POST', post, }; }la sua tipologia può essere definita come segue:
type ReceiveNewPostAction = { type: 'RECEIVE_NEW_POST'; post: Post; };Facile, vero?
Ora diamo un'occhiata al nostro riduttore: prende uno state e action (di cui non conosciamo ancora il tipo) e produce un nuovo State :
function reducer( state: State, action: any ): State { ... } Il nostro negozio ha due diversi tipi di azioni: UpdatePostAction e ReceiveNewPostAction . Allora, qual è il tipo di argomento action ? Uno o l'altro, giusto? Quando una variabile può accettare più di un tipo A , B , C e così via, il suo tipo è un'unione di tipi. Cioè, il suo tipo può essere A o B o C e così via. Un tipo di unione è un tipo i cui valori possono essere di qualsiasi tipo specificato in tale unione.
Ecco come il nostro tipo di Action può essere definito come un tipo di unione:
type Action = UpdatePostAction | ReceiveNewPostAction; Il frammento di codice precedente indica semplicemente che Action può essere un'istanza del tipo UpdatePostAction o un'istanza del tipo ReceiveNewPostAction .
Se ora usiamo Action nel nostro riduttore:
function reducer( state: State, action: Action ): State { ... }possiamo vedere come questa nuova versione del nostro codice, che è ben digitato, funziona senza problemi.
Come i tipi di unione eliminano i casi predefiniti
"Aspetta un secondo", potresti dire, "il collegamento precedente non funziona correttamente, il compilatore sta attivando un errore!" Infatti, secondo TypeScript, il nostro riduttore contiene codice irraggiungibile:
function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... } return state; //Error! Unreachable code }Aspetta cosa? Lascia che ti spieghi cosa sta succedendo qui...
Il tipo di unione Action che abbiamo creato è in realtà un tipo di unione discriminato. Un tipo di unione discriminato è un tipo di unione in cui tutti i suoi tipi condividono un attributo comune il cui valore può essere utilizzato per discriminare un tipo dall'altro.
Nel nostro caso, i due tipi di Action hanno un attributo type i cui valori sono RECEIVE_NEW_POST per ReceiveNewPostAction e UPDATE_POST per UpdatePostAction . Poiché sappiamo che Action è, necessariamente, un'istanza di un'azione o dell'altra, i due rami del nostro switch coprono tutte le possibilità: o action.type è RECEIVE_NEW_POST o è UPDATE_POST . Pertanto, il return finale è ridondante e sarà irraggiungibile.
Supponiamo, quindi, di rimuovere quel return per correggere questo errore. Abbiamo ottenuto qualcosa, oltre alla rimozione del codice non necessario? La risposta è si. Se ora aggiungiamo un nuovo tipo di azione nel nostro codice:
type Action = | UpdatePostAction | ReceiveNewPostAction | NewFeatureAction; type NewFeatureAction = { type: 'NEW_FEATURE'; // ... }; improvvisamente l'istruzione switch nel nostro riduttore non copre più tutti i possibili scenari:
function reducer( state: State, action: Action ): State { // ... switch ( action.type ) { case 'RECEIVE_NEW_POST': // ... case 'UPDATE_POST': // ... // case NEW_FEATURE is missing... } // return undefined is now implicit } Ciò significa che il riduttore potrebbe restituire implicitamente un valore undefined se lo invochiamo utilizzando un'azione di tipo NEW_FEATURE , e questo è qualcosa che non corrisponde alla firma della funzione. A causa di questa mancata corrispondenza, TypeScript si lamenta e ci informa che manca un nuovo ramo per gestire questo nuovo tipo di azione.
Funzioni polimorfiche con tipi di ritorno variabili
Se sei arrivato fin qui, congratulazioni: hai imparato tutto quello che devi fare per migliorare il codice sorgente delle tue applicazioni JavaScript usando TypeScript. E, come ricompensa, condividerò con voi un “problema” che mi sono imbattuto qualche giorno fa e la sua soluzione. Come mai? Perché TypeScript è un mondo complesso e affascinante e voglio mostrarti fino a che punto questo è vero.
All'inizio di tutta questa avventura, abbiamo visto uno dei selettori che abbiamo è getPostsInDay e come il suo tipo restituito è un elenco di ID post:
function getPostsInDay( state: State, day: Day ): PostId[] { return state.days[ day ] ?? []; }anche se il nome suggerisce che potrebbe restituire un elenco di post. Perché ho usato un nome così fuorviante, ti stai chiedendo? Bene, immagina il seguente scenario: supponi di volere che questa funzione sia in grado di restituire un elenco di ID post o un elenco di post effettivi, a seconda del valore di uno dei suoi argomenti. Qualcosa come questo:
const ids: PostId[] = getPostsInDay( '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( '2020-10-01', 'all' );Possiamo farlo in TypeScript? Certo che lo facciamo! Perché altrimenti dovrei sollevare questo argomento altrimenti? Tutto quello che dobbiamo fare è definire una funzione polimorfica il cui risultato dipende dai parametri di input.
Quindi, l'idea è che vogliamo due versioni diverse della stessa funzione. Si dovrebbe restituire un elenco di PostId s se uno degli attributi è la string id . L'altro dovrebbe restituire un elenco di Post se lo stesso attributo è la string all .
Creiamoli entrambi:
function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[] { // ... } function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[] { // ... }Facile, vero? SBAGLIATO! Questo non funziona. Secondo TypeScript, abbiamo una "implementazione di funzione duplicata".
Ok, allora proviamo qualcosa di diverso. Uniamo le due definizioni precedenti in un'unica funzione:
function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' = 'id' ): PostId[] | Post[] { if ( 'id' === mode ) { return state.days[ day ] ?? []; } return []; }Questo si comporta come vogliamo? temo che non sia così...
Ecco cosa ci dice questa funzione signature: “ getPostsInDay è una funzione che accetta due argomenti, uno state e una mode i cui valori possono essere id o all ; il suo tipo restituito sarà un elenco di PostId s o un elenco di Post s." In altre parole, la definizione di funzione precedente non specifica da nessuna parte che esiste una relazione tra il valore assegnato all'argomento mode e il tipo restituito della funzione. E quindi codice come questo:
const state: State = { posts: {}, days: {} }; const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );è valido e non si comporta come vorremmo.
Ok, ultimo tentativo. E se mescoliamo la nostra intuizione iniziale, in cui descriviamo firme di funzioni concrete, con un'unica implementazione valida?
function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[]; function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[]; function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' ): PostId[] | Post[] { const postIds = state.days[ day ] ?? []; if ( 'id' === mode ) { return postIds; } return postIds .map( ( pid ) => getPost( state, pid ) ) .filter( ( p ): p is Post => !! p ); } Il frammento di codice precedente ha un'implementazione di funzione valida che funziona, ma definisce due firme di funzione aggiuntive che legano valori concreti in mode con il tipo restituito della funzione.
Utilizzando questo approccio, questo codice è valido:
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );e questo no:
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'all' ); const posts: Post[] = getPostsInDay( state, '2020-10-01', 'id' );Conclusioni
In questa serie di post abbiamo visto cos'è TypeScript e come possiamo applicarlo ai nostri progetti. I tipi ci aiutano a documentare meglio il codice fornendo un contesto semantico. Inoltre, i tipi aggiungono anche un ulteriore livello di sicurezza, dal momento che il compilatore TypeScript si occupa di convalidare che il nostro codice combaci correttamente, proprio come fanno i Lego.
A questo punto hai già tutti gli strumenti necessari per portare la qualità del tuo lavoro al livello successivo. Buona fortuna in questa nuova avventura!
Immagine in primo piano di Mike Kenneally su Unsplash.
