Añadiendo TypeScript a un store creado con el paquete «wordpress/data»

Publicada en WordPress.

Mira nuestro vídeo

Existe una versión mejor de tu web

Comparte este artículo

El año pasado estuvimos hablando en varias entradas sobre TypeScript. En una de mis últimas entradas, te enseñé cómo usar TypeScript en tus plugins para WordPress a través de un ejemplo real y, en concreto, vimos cómo mejorar un store Redux añadiendo tipos a nuestros selectores, acciones y reductores.

Si recuerdas, pasamos de un código JavaScript como este:

// Selectors
function getPost( state, id ) { … }
function getPostsInDay( state, day ) { … }

// Actions
function receiveNewPost( post ) { … }
function updatePost( postId, attributes ) { … }

// Reducer
function reducer( state, action ) { … }

en donde lo único que nos daba pistas sobre qué hace cada función y qué es cada parámetro depende de nuestro buen ojo a la hora de dar nombres, a un código TypeScript como este:

// 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 { … }

el cual deja mucho más claro qué es qué, especialmente si tenemos acceso a la definición de los diferentes tipos que usamos en nuestro plugin:

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;
};

Pues bien, el otro día estaba trabajando en nuestro nuevo plugin, Nelio Unlocker, y al aplicar todas estas técnicas me topé con un problema que, por un instante, pensé que no tendría solución. Pero, como suele ser habitual, todo tiene solución…

El problema

Cuando queremos usar los selectores o acciones que hemos definido en nuestro store, no los importamos directamente, sino que accedemos a ellos a través de los hooks de React (con useSelect y useDispatch) o con higher-order components (con withSelect y withDispatch), todos ellos disponibles a través del paquete @wordpress/data.

Por ejemplo, si quisiéramos usar el selector getPost y la acción updatePost que te he enseñado en el apartado anterior, haríamos algo como esto (suponiendo que ambos forman parte de un store llamado nelio-store):

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

Y precisamente ahí es donde surge la duda: ¿cómo podemos decirle a TypeScript que el resultado de acceder a select('nelio-store') es un objeto que contiene todos nuestros selectores? ¿Y cómo le decimos que dispatch('nelio-store') es lo mismo, pero con nuestras acciones?

La solución

En nuestra última entrada sobre TypeScript hablamos de funciones polimórficas. Si recuerdas, las funciones polimórficas nos permiten especificar diferentes tipos de resultado en función de los argumentos que le pasamos a la función. Y esto es precisamente lo que queremos: usando el polimorfismo de TypeScript podemos especificar que, cuando llamemos a los métodos select o dispatch de @wordpress/data con el nombre de nuestro store como parámetro, el resultado que obtenemos sean nuestros selectores o nuestras acciones respectivamente.

Para ello, debemos meter el bloque declare module en el fichero donde registramos nuestro store:

// 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;
}

y a continuación únicamente debemos definir los tipos Selectors y 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;
}

Si te fijas, estos tipos incluyen todos nuestros selectores y acciones, pero con pequeños cambios. En el caso de los selectores, lo que hemos hecho ha sido quitar el primer parámetro state, porque este no está presente cuando llamamos un selector desde select. Y para las acciones hemos quitado el tipo de retorno, dejándolo en void, porque las acciones de dispatch no devuelven nada. Estos cambios se deben a que tanto el state de los selectores como el tipo de retorno de las acciones son detalles de implementación que necesita el store para funcionar, pero son irrelevantes e innecesarios cuando los usamos desde nuestros componentes.

Manipulando tipos de función en TypeScript

Si echamos un vistazo a los tipos de actions y selectors, veremos que TypeScript nos dice lo siguiente:

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;
}

Es decir, casi (¡casi!) tenemos lo que queremos. Si pudiéramos manipular los tipos que TypeScript ha detectado para quitar el parámetro que nos sobra en los selectores y poner void como tipo de retorno en las acciones, podríamos especificar los tipos Selectors y Actions de forma dinámica para que siempre sean un reflejo del conjunto de selectores y acciones que tengamos definidos.

Cómo quitar el primer parámetro al tipo de una función en TypeScript

Vamos a centrarnos por un momento en el selector getPost. Su tipo es el siguiente:

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

En este caso lo que queremos es un mecanismo que nos permita generar un nuevo tipo a partir de otro tipo existente. Esto, en TypeScript, lo podemos lograr combinando varias funcionalidades avanzadas del lenguaje.

Veamos primero la solución completa:

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

Complicado, ¿eh? Tranquilo, que vamos a destriparla paso a paso:

  • type OmitFirstArg<F>. Lo primero que hacemos es crear un nuevo tipo auxiliar (OmitFirstArg) genérico. En general, un tipo genérico nos permite crear nuevos tipos a partir de tipos existentes. Por ejemplo, un tipo genérico que seguro que conoces es Array<T> para crear listas de elementos de tipo T: Array<string> representa una lista de strings, Array<Post> representa una lista de Post, etc. Pues bien, OmitFirstArg<F> será el mecanismo a través del cual podremos quitar el primer parámetro de una función.
  • Al tratarse de un tipo genérico, en teoría podríamos usarlo con cualquier otro tipo de TypeScript. Es decir, cosas como OmitFirstArg<string> u OmitFirstArg<Post> son posibles… pero nosotros sabemos que este tipo auxiliar sólo debe usarse con funciones, así que vamos a definir este tipo como un tipo condicional. Usando un tipo condicional, podemos especificar lo siguiente: «si F es una función con como mínimo un parámetro, el resultado es otro tipo función equivalente a F en el que hemos quitado ese primer parámetro; sino, devuelve el tipo never».
  • F extends XXX. Esta es la fórmula que usamos para especificar que el tipo genérico F tiene una cierta forma. ¿Que quieres verificar que F sea un string? Escribe: F extends string. Fácil. Pero queremos que sea una función con, como mínimo, un parámetro, ¿recuerdas?
  • (x: any, ...args: infer P) => infer R. Este es un tipo que define de forma universal una función con un parámetro como mínimo (x, cuyo tipo concreto me da igual, con lo que lo especifico como any). Este tipo tiene dos cosas interesantes. Por un lado, usamos el operador de resto para capturar la definición de los demás parámetros de la función (si los hubiere) en el nombre args. Por otro lado, para conocer los tipos que tienen dichos argumentos o el tipo de retorno de la función, usamos la inferencia de tipos de TypeScript. Usando la palabra clave infer capturamos los tipos del resto de argumentos en P y el tipo de retorno de la función en R.
  • ? (...args: P) => R : never. Si F era una función como la que acabamos de describir, el tipo de retorno será una nueva función con una lista de argumentos con los tipos que hemos capturado en P y tipo de retorno R. Sino, devolvemos never.

Ahora, si usamos este tipo auxiliar, podemos hacer lo siguiente:

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

OmitFirstArg< typeof getPost > === ( id: PostId ) => Post | undefined;

¡y ya estamos un paso más cerca de conseguir lo que queremos! Aquí te dejo el ejemplo en el Playground de TypeScript.

Cómo cambiar el tipo de resultado al tipo de una función en TypeScript

Con todo lo que hemos visto, generar un tipo auxiliar que quite el tipo de retorno de una función debería ser pan comido… pero, por si acaso, voy a compartir contigo la solución:

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

Como ves, lo único que hemos cambiado ha sido eliminar el requisito de que la función tenga como mínimo un parámetro y ya no capturamos el tipo de retorno original (nos vale cualquier cosa any). Si el tipo F es una función como la que queremos, el resultado será otra función con los mismos args que devolverá void; sino, devolvemos never y listo.

Aquí tienes el resultado en Playground.

Cómo mapear un tipo de objeto a otro tipo de objeto en TypeScript

Tal y como hemos visto un poco más arriba, nuestras acciones y nuestros selectores son dos objetos cuyas claves son los nombres de dichas acciones y selectores y cuyos valores son las funciones en sí. Esto quiere decir que los tipos de dichos objetos tienen la siguiente pinta:

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;
}

En los dos apartados anteriores hemos aprendido cómo transformar un tipo de función en otro tipo de función. Esto quiere decir que podríamos definir nuevos tipos a mano así:

type Selectors = {
  getPost: OmitFirstArg< typeof selectors.getPost >;
  getPostsInDay: OmitFirstArg< typeof selectors.getPostsInDay >;
};

type Actions = {
  receiveNewPost: RemoveReturnType< actions.receiveNewPost >;
  updatePost: RemoveReturnType< actions.updatePost >;
};

Pero, claro, esto no es sostenible en el tiempo. Si cambio las acciones o los selectores, tengo que acordarme de cambiar estos tipos que estoy definiendo a mano… y, además, da la sensación que estoy repitiendo el trabajo, porque las claves de Selectors son las mismas que las que tengo en selectors, y lo mismo con las acciones.

Está claro que lo que queremos aquí es mapear un tipo de objeto a otro. En concreto, queremos mapear por un lado un objeto con selectores a otro objeto donde no está el primer argumento en cada selector y, por otro lado, un objeto de acciones a otro objeto donde las acciones devuelve void:

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

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

Imagino que a estas alturas ya has sido capaz de descifrar qué hace los tipos genéricos auxiliares que acabamos de crear, pero por si acaso:

  • type OmitFirstArgs<O>. Empezamos creando un tipo genérico que recibe como parámetro lo que, asumismos, será un objeto de tipo O.
  • El resultado de esto será otro objeto (tal y como las llaves {...} a la derecha de la igualdad indica).
  • [K in keyof O]. No sabemos qué claves tendrá exactamente el nuevo tipo, pero lo que sí sabemos es que, sean cuales sean, serán las que ya estaban en el objeto original O. Con esta notación capturamos todas las claves del objeto O y le damos un nombre K.
  • A la derecha de esta «clave genérica K» especificamos su tipo: OmitFirstArg<O[K]>. Es decir, recuperamos el tipo original que teníamos en O (el cual es O[K]) y lo manipulamos con nuestro tipo genérico auxiliar original (en este caso, OmitFirstArg).
  • Para RemoveReturnTypes hacemos exactamente lo mismo, pero usando el otro tipo genérico auxiliar que hemos creado antes: RemoveReturnType.

Extendiendo @wordpress/data con nuestros selectores y acciones

Si metes los tipos auxiliares genéricos que hemos visto en un fichero global.d.ts y lo guardas en la raíz de tu proyecto, podemos por fin combinar todo lo que hemos visto hoy para solucionar el problema original:

// 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;
}

¡Y eso es todo! Espero que te haya gustado este pequeño truco y, si es así, ya sabes: compártelo con tus compañeros y amigos. Y si conoces otra alternativa para hacer esto de una mejor forma, dímelo en los comentarios.

Imagen destacada de Gabriel Crismariu en Unsplash.

Deja una respuesta

No publicaremos tu correo electrónico. Los campos obligatorios están marcados con: •

He leído y acepto la Política de privacidad de Nelio Software

Al marcar la casilla de aceptación estás dando tu legítimo consentimiento para que tu información personal se almacene en SiteGround y sea usada por Nelio Software con el propósito único de publicar aquí este comentario. Contáctanos para corregir, limitar, eliminar o acceder a tu información.