Aggiunta di TypeScript a @wordpress/data Store

Pubblicato: 2021-02-05

L'anno scorso abbiamo parlato molto di TypeScript. In uno dei miei ultimi post abbiamo visto come utilizzare TypeScript nei plugin di WordPress attraverso un esempio reale e, in particolare, come migliorare un negozio Redux aggiungendo tipi ai nostri selettori, azioni e riduttori.

In questo esempio, siamo passati dal codice JavaScript di base in questo modo:

 // Selectors function getPost( state, id ) { … } function getPostsInDay( state, day ) { … } // Actions function receiveNewPost( post ) { … } function updatePost( postId, attributes ) { … } // Reducer function reducer( state, action ) { … }

dove l'unica cosa che ci ha fornito indizi su cosa fa ogni funzione e cosa è ogni parametro dipende dalle nostre capacità di denominazione, alla seguente controparte TypeScript migliorata:

 // Selectors function getPost( state: State, id: PostId ): Post | undefined { … } function getPostsInDay( state: State, day: Day ): PostId[] { … } // Actions function receiveNewPost( post: Post ): ReceiveNewPostAction { … } function updatePost( postId: PostId, attributes: Partial<Post> ): UpdatePostAction { … } // Reducer function reducer( state: State, action: Action ): State { … }

il che rende tutto molto più chiaro, poiché tutto è digitato correttamente:

 type PostId = number; type Day = string; type Post = { id: PostId; title: string; author: string; day: Day; status: string; isSticky: boolean; }; type State = { posts: Dictionary; days: Dictionary; };

Un paio di settimane fa stavo lavorando al nostro nuovo plugin, Nelio Unlocker, e ho riscontrato un problema durante l'applicazione di tutte queste tecniche. Quindi esaminiamo detto problema e impariamo come superarlo!

Il problema

Come forse già saprai, quando vogliamo utilizzare i selettori e/o le azioni che abbiamo definito nel nostro store, lo facciamo accedendo ad essi tramite hook React (con useSelect e useDispatch ) o tramite componenti di ordine superiore (con withSelect e withDispatch ) , tutti forniti dal pacchetto @wordpress/data .

Ad esempio, se volessimo utilizzare il selettore getPost e l'azione updatePost che abbiamo appena visto, tutto ciò che dobbiamo fare è qualcosa del genere (supponendo che il nostro negozio si chiami nelio-store ):

 const Component = ( { postId } ): JSX.Element => { const post = useSelect( ( select ): Post => select( 'nelio-store' ).getPost( postId ); ); const { updatePost } = useDispatch( 'nelio-store' ); return ( ... ); };

Nello snippet precedente puoi vedere che accediamo ai nostri selettori e azioni usando gli hook React. Ma come diavolo fa TypeScript a sapere che esistono quei selettori e azioni, per non parlare di quali sono i suoi tipi?

Bene, questo è esattamente il problema che ho dovuto affrontare. Cioè, volevo sapere come potevo dire a TypeScript che il risultato dell'accesso a select('nelio-store') è un oggetto che contiene tutti i nostri selettori del negozio e dispatch('nelio-store') è un oggetto con le nostre azioni del negozio .

La soluzione

Nel nostro ultimo post su TypeScript abbiamo parlato di funzioni polimorfiche. Le funzioni polimorfiche ci consentono di specificare diversi tipi restituiti in base agli argomenti forniti. Bene, usando il polimorfismo TypeScript possiamo specificare che, quando chiamiamo i metodi select o dispatch del pacchetto @wordpress/data con il nome del nostro negozio come parametro, il risultato che otteniamo sono rispettivamente i nostri selettori e le nostre azioni.

Per fare ciò, aggiungi semplicemente un blocco di declare module nel file in cui registriamo il nostro negozio come segue:

 // WordPress dependencies import { registerStore } from '@wordpress/data'; import { controls } from '@wordpress/data-controls'; // Internal dependencies import reducer from './reducer'; import * as actions from './actions'; import * as selectors from './selectors'; const STORE = 'nelio-store'; registerStore( STORE, { controls, reducer, actions, selectors, } ); // Extend @wordpress/data with our store declare module '@wordpress/data' { function select( key: typeof STORE ): Selectors; function dispatch( key: typeof STORE ): Actions; }

e quindi definire quali sono effettivamente i tipi di Selectors e Actions :

 type Selectors = { getPost: ( id: PostId ) => Post | undefined; getPostsInDay: ( day: Day ) => PostId[]; } type Actions = { receiveNewPost: ( post: Post ) => void; updatePost: ( postId: PostId, attributes: Partial<Post> ) => void; }

Fin qui tutto bene, giusto? L'unico "problema" è che dobbiamo definire manualmente i tipi di Selectors e Actions , il che suona strano dato che TypeScript sa già che abbiamo una serie di selectors e actions correttamente tipizzati...

Manipolazione dei tipi di funzione in TypeScript

Se diamo un'occhiata ai tipi di actions e selectors che abbiamo importato, vedremo che TypeScript ci dice quanto segue:

 typeof selectors === { getPost: ( state: State, id: PostId ) => Post | undefined; getPostsInDay: ( state: State, day: Day ) => PostId[]; } typeof actions === { receiveNewPost: ( post: Post ) => ReceiveNewPostAction; updatePost: ( postId: PostId, attributes: Partial<Post> ) => UpdatePostAction; }

Come puoi vedere, i loro tipi sono una copia esatta dei tipi che abbiamo definito manualmente nella sezione precedente. Bene, quasi esatto: ai selettori manca il loro primo argomento (lo state store , perché non è presente quando chiamiamo un selettore da select ) e le azioni restituiscono void (poiché le azioni chiamate tramite dispatch non restituiscono nulla).

Possiamo usarli per generare automaticamente i tipi di Selectors e Actions di cui abbiamo bisogno?

Come rimuovere il primo parametro di un tipo di funzione in TypeScript

Concentriamoci per un momento sul selettore getPost . La sua tipologia è la seguente:

 // Old type typeof getPost === ( state: State, id: PostId ) => Post | undefined

Come abbiamo appena detto, abbiamo bisogno di un nuovo tipo di funzione che non abbia il parametro state :

 // New type ( id: PostId ) => Post | undefined

Quindi, abbiamo bisogno di TypeScript per generare un nuovo tipo da un tipo già esistente. Ciò può essere ottenuto combinando diverse funzionalità avanzate del linguaggio:

 type OmitFirstArg< F > = F extends ( x: any, ...args: infer P ) => infer R ? ( ...args: P ) => R : never;

Complicato, eh? Diamo un'occhiata più da vicino a cosa sta succedendo qui:

  • type OmitFirstArg<F> . Innanzitutto definiamo un nuovo tipo generico ausiliario ( OmitFirstArg ) . In generale, un tipo generico è un tipo che consente di definire nuovi tipi da tipi già esistenti. Ad esempio, probabilmente hai familiarità con il tipo Array<T> , poiché ti consente di creare elenchi di cose: Array<string> è un elenco di stringhe, Array<Post> è un elenco di Post , ecc. Bene, di seguito questa nozione, OmitFirstArg<F> è un tipo di supporto che rimuove il primo argomento di una funzione.
  • Poiché questo è un tipo generico, potremmo teoricamente usarlo con qualsiasi altro tipo TypeScript. Cioè, cose come OmitFirstArg<string> e OmitFirstArg<Post> sono possibili... anche se sappiamo che questo tipo dovrebbe essere usato solo con funzioni che hanno almeno un argomento. Per assicurarci che questo tipo di supporto venga utilizzato solo con le funzioni, lo definiremo come tipo condizionale. Il tipo condizionale permette di specificare quale dovrebbe essere il tipo risultante in base a una condizione: “se F è una funzione con almeno un argomento (condizione), il tipo risultante è un'altra funzione in cui il primo argomento è stato rimosso (digitare quando la condizione è vero); in caso contrario, utilizzare il tipo never (digitare quando la condizione è falsa)."
  • F extends XXX . Questa è la formula per specificare la condizione. Vuoi verificare che F sia una stringa? Basta digitare: F extends string . Vai tranquillo. Ma che dire di "una funzione con un argomento?" Sembra sicuramente più complicato...
  • (x: any, ...args: infer P) => infer R . Questo è un tipo di funzione: iniziamo con gli argomenti (tra parentesi), seguiti da una freccia, seguita dal tipo restituito della funzione. In questo caso particolare, richiediamo che la funzione abbia un argomento x (il cui tipo specifico è irrilevante). Questa definizione di tipo ha due bit interessanti. Da un lato, utilizziamo l'operatore rest per acquisire i tipi P degli args rimanenti (se presenti). D'altra parte, utilizziamo l'inferenza del tipo di TypeScript ( infer ) per sapere quali sono realmente questi tipi P , nonché l'esatto tipo restituito R .
  • ? (...args: P) => R : never ? (...args: P) => R : never . Infine, completiamo il tipo condizionale. Se F era una funzione, il tipo restituito è una nuova funzione i cui argomenti sono di tipo P e il cui tipo restituito è R . In caso contrario, il tipo restituito non è never .

Ecco come possiamo usare questo tipo di supporto per creare il nuovo tipo che volevamo:

 const getPost = ( state: State, id: PostId ) => Post | undefined; OmitFirstArg< typeof getPost > === ( id: PostId ) => Post | undefined;

e siamo già un passo più vicini al raggiungimento di ciò che vogliamo! Qui puoi vedere questo esempio nel playground.

Come modificare il tipo restituito di un tipo di funzione in TypeScript

Sono sicuro che conosci già la risposta per sapere: abbiamo bisogno di un tipo generico ausiliario che accetti un tipo di funzione e restituisca un nuovo tipo di funzione. Qualcosa come questo:

 type RemoveReturnType< F > = F extends ( ...args: infer P ) => any ? ( ...args: P ) => void : never;

Facile, vero? È abbastanza simile a quello che abbiamo fatto nella sezione precedente: catturiamo i tipi degli args in P (non è necessario richiedere almeno un argomento x questa volta) e ignoriamo il tipo restituito. Se F è una funzione, restituisce una nuova funzione che restituisce void . In caso contrario, non tornare never . Stupendo!

Dai un'occhiata al parco giochi.

Come mappare un tipo di oggetto su un altro tipo di oggetto in TypeScript

Le nostre azioni ei nostri selettori sono due oggetti le cui chiavi sono i nomi di quelle azioni e selettori e i cui valori sono le funzioni stesse. Ciò significa che i tipi di questi oggetti sono simili ai seguenti:

 typeof selectors === { getPost: ( state: State, id: PostId ) => Post | undefined; getPostsInDay: ( state: State, day: Day ) => PostId[]; } typeof actions === { receiveNewPost: ( post: Post ) => ReceiveNewPostAction; updatePost: ( postId: PostId, attributes: Partial<Post> ) => UpdatePostAction; }

Nelle due sezioni precedenti abbiamo imparato come trasformare un tipo di funzione in un altro tipo di funzione. Ciò significa che potremmo definire nuovi tipi a mano in questo modo:

 type Selectors = { getPost: OmitFirstArg< typeof selectors.getPost >; getPostsInDay: OmitFirstArg< typeof selectors.getPostsInDay >; }; type Actions = { receiveNewPost: RemoveReturnType< actions.receiveNewPost >; updatePost: RemoveReturnType< actions.updatePost >; };

Ma, ovviamente, questo non è sostenibile nel tempo: stiamo specificando manualmente i nomi delle funzioni in entrambi i tipi. Chiaramente, vogliamo mappare automaticamente le definizioni dei tipi originali di actions e selectors a nuovi tipi.

Ecco come puoi farlo in TypeScript:

 type OmitFirstArgs< O > = { [ K in keyof O ]: OmitFirstArg< O[ K ] >; } type RemoveReturnTypes< O > = { [ K in keyof O ]: RemoveReturnType< O[ K ] >; }

Si spera che questo abbia già senso, ma svolgiamo rapidamente ciò che fa comunque lo snippet precedente:

  • type OmitFirstArgs<O> . Creiamo un nuovo tipo generico ausiliario che accetta un oggetto O .
  • Il risultato è un altro tipo di oggetto (come rivelano le parentesi graffe {...} ).
  • [K in keyof O] . Non conosciamo le chiavi esatte che avrà il nuovo oggetto, ma sappiamo che devono essere le stesse chiavi di quelle incluse in O . Quindi è quello che diciamo a TypeScript: vogliamo che tutte le chiavi K siano una keyof O .
  • E quindi, per ogni chiave K , il suo tipo è OmitFirstArg<O[K]> . Cioè, otteniamo il tipo originale ( O[K] ) e lo trasformiamo nel tipo che vogliamo usando il tipo ausiliario che abbiamo definito (in questo caso, OmitFirstArg ).
  • Infine, facciamo lo stesso con RemoveReturnTypes e il tipo ausiliario originale RemoveReturnType .

Estendere @wordpress/data con i nostri selettori e azioni

Se aggiungi i quattro tipi ausiliari che abbiamo visto oggi in un file global.d.ts e lo salvi nella radice del tuo progetto, puoi finalmente combinare tutto ciò che abbiamo visto in questo post per risolvere il problema originale:

 // WordPress dependencies import { registerStore } from '@wordpress/data'; import { controls } from '@wordpress/data-controls'; // Internal dependencies import reducer from './reducer'; import * as actions from './actions'; import * as selectors from './selectors'; // Types type Selectors = OmitFirstArgs< typeof selectors >; type Actions = RemoveReturnTypes< typeof actions >; const STORE = 'nelio-store'; registerStore( STORE, { controls, reducer, actions, selectors, } ); // Extend @wordpress/data with our store declare module '@wordpress/data' { function select( key: typeof STORE ): Selectors; function dispatch( key: typeof STORE ): Actions; }

E questo è tutto! Spero che questo suggerimento per gli sviluppatori ti sia piaciuto e, se l'hai fatto, condividilo con i tuoi colleghi e amici. Oh! E se conosci un approccio diverso per ottenere lo stesso risultato, dimmelo nei commenti.

Immagine in primo piano di Gabriel Crismariu su Unsplash.