30%dto
Oferta Cyber Monday

Cómo crear mejores componentes con TypeScript y React Hooks (I)

Publicada en WordPress.

Una de las cosas que más me gusta de trabajar con mis socios de Nelio, Ruth y Toni, es la posibilidad de escoger los proyectos que vamos a realizar y cómo los vamos a implementar. Como informáticos, los tres disfrutamos especialmente aprendiendo y probando nuevas tecnologías. Si juntamos ambos factores, tenemos que en Nelio siempre buscamos cualquier excusa para añadir algo nuevo en nuestros desarrollos, procurando hacer las cosas un poquito mejor de lo que lo hicimos en iteraciones anteriores.

En la entrada de hoy veremos la primera parte de cómo una buena definición en TypeScript del modelo de datos de nuestro plugin nos puede guiar durante todo el desarrollo, permitiéndonos crear un software más robusto, confiable y mantenible. Y, además, te lo voy a explicar con un ejemplo real, Nelio Popups, del cual te habló Ruth hace unos días. Espero que disfrutes y que, como a nosotros, te entren las ganas de probarlo en tus próximos trabajos.

Un repaso a TypeScript

Como ya hemos comentado en entradas anteriores, TypeScript es un lenguaje de programación que nace con la intención de extender JavaScript añadiéndole tipos y alguna que otra construcción adicional. La idea es que a través de un tipado fuerte de nuestro código JavaScript podemos evitar errores en tiempo de ejecución, ya que el compilador de TypeScript los detectará en tiempo de compilación.

Por ejemplo, imagina que tenemos un objeto con información sobre un usuario del sistema: su nombre, su contraseña, etc.

const p = {
  name: 'David',
  password: 'some-nice-password',
};

Supón también que tenemos una función que verifica si la contraseña es segura (mirando que tiene 8 caracteres o más) y que decidimos usarla con el objeto anterior:

function hasSafePassword( person ) {
  return 8 <= person.pasword.length;
}
hasSafePassword( p ); // ERROR!

Pues bien, resulta que en menos de 10 líneas de código, tenemos un error que, a simple vista, cuesta de pillar:

Uncaught TypeError: person.pasword is undefined
    hasSafePassword

¿Qué ha pasado? Muy sencillo: el atributo pasword no existe, ¡porque lo hemos escrito mal! En realidad es password con dos s… Pero este error solo lo descubrimos cuando ejecutamos el código.

Por otro lado, si hubiéramos implementado este mismo código usando TypeScript y definiendo qué pinta tiene el objeto Person:

type Person = {
  readonly name: string;
  readonly password: string;
};
// A
const p: Person = {
  name: 'David',
  password: 'some-nice-password',
};
// B
function hasSafePassword( person: Person ) {
  return 8 <= person.pasword.length;
}
// C
hasSafePassword( p );

el compilador hubiera sido capaz de detectar el error de buenas a primeras:

Property 'pasword' does not exist on type 'Person'. Did you mean 'password'?

Un ejemplo sencillo que, a poco que hayas picado un par de líneas en JavaScript, seguro que has vivido.

Union Types

En mi opinión, una de las cosas más interesantes de TypeScript es que podemos especificar que una cierta variable o parámetro puede tener diferentes tipos. En el ejemplo anterior, por ejemplo, hemos visto que la constante p tiene que ser exactamente de tipo Person, así como también el parámetro person de la función hasSafePassword. Ahora bien, hay casos en los que puede interesarnos que una misma variable o parámetro pueda tener diferentes tipos. Un ejemplo paradigmático de ello son los reducers de un almacén Redux.

Tal y como ya vimos en su día, un almacén Redux nos permite almacenar el estado de nuestra aplicación. Para actualizarlo, disponemos de un método reducer que toma dos parámetros (el estado actual de nuestra aplicación y una acción con la información necesaria para actualizarlo) y devuelve un nuevo estado:

function reducer( state: State, action: Action ): State

Lógicamente, las acciones que pueden llegar al reducer no tienen por qué ser todas iguales. Por ejemplo, si nuestra aplicación es una lista de tareas (la típica TODO list), podemos tener acciones como:

  • Crear una tarea: con un ID y la descripción de la tarea a realizar, da de alta una nueva tarea pendiente en el estado de la aplicación.
  • Borrar una tarea: dado el ID de una tarea, la elimina de la aplicación.
  • Cambiar el estado de una tarea: dado el ID de una tarea, si estaba pendiente la pasa a completada y viceversa.
type NewTask = {
  readonly type: 'NEW_TASK';
  readonly id: string;
  readonly task: string;
}
type RemoveTask = {
  readonly type: 'REMOVE_TASK';
  readonly id: string;
}
type ToggleTaskStatus = {
  readonly type: 'TOGGLE_TASK_STATUS';
  readonly id: string;
}

Como ves, las acciones de esta pequeña aplicación pueden ser de tipo o bien NewTask, o bien RemoveTask, o bien ToggleTaskStatus (serán de un tipo u otro, pero no de dos tipos a la vez). Esto es lo que se conoce como «unión de tipos» o «union types», y en TypeScript lo representamos con el símbolo de pipe:

type Action =
  | NewTask
  | RemoveTask
  | ToggleTaskStatus;

Type Guards

Cuando estamos trabajando con una variable o parámetro cuyo tipo es una unión de tipos, a menudo necesitaremos saber qué tipo exacto tiene antes de poder manipularlo. Por ejemplo, todas las acciones del ejemplo anterior son, según los tipos que hemos definido, objetos con una propiedad type (algo estándar en los almacenes Redux) y otra id. Sin embargo, una de esas acciones (NewTask) tiene una propiedad adicional, task, la cual solo estará disponible cuando la acción que recibamos sea de tipo NewTask.

Si queremos acceder al atributo task de la action que entra en nuestro reducer, primero debemos comprobar que la action es de tipo NewTask. Lógico, ¿no? A esta comprobación se la conoce como «guardia de tipo» o «type guard» y nos permite acotar y conocer el tipo concreto que tiene una variable en un momento dado.

En el caso de las acciones, dicha comprobación es súper sencilla:

function reducer( state: State, action: Action ): State {
  switch ( action.type ) {
    case 'NEW_TASK':
      return {
         ...state,
         [ action.id ]: {
           completed: false,
           task: action.task,
         }
      };
    ...
  }
}

porque todas ellas tienen el atributo type y, por lo tanto, podemos usarlo en un switch para saber qué tipo de acción es. De esta forma, cuando type es NEW_TASK sabemos con certeza que tenemos una acción de tipo NewTask y, por lo tanto, el objeto que estamos recibiendo en el reducer tendrá el atributo task.

A este tipo de unión de tipos se le llama «unión discriminada de tipos» o «discriminated union types» porque, como su nombre indica, se trata de una unión de tipos en la que disponemos de un atributo (en este caso, type) que nos permite discriminar el tipo exacto que tiene.

Pero no todas las uniones de tipo tienen por qué ser discriminadas. Es perfectamente factible que dos tipos sean totalmente diferentes y no dispongan de un atributo común con el que discriminarlos:

type Element = Post | Task;
type Post = {
  readonly id: number;
  readonly title: string;
}
type Task = {
  readonly id: number | string;
  readonly task: string;
  readonly completed: boolean;
}

¿Cómo podemos saber si, dado un Element, se trata en realidad de un Post o una Task? Si ambos elementos tuvieran un discriminador, sabríamos cómo hacerlo… pero no lo tenemos. ¿Entonces?

En casos como este, la solución pasa por usar «predicados de tipo» o «type predicates». Básicamente son funciones predicado (es decir, funciones que devuelven un boolean) que, al devolver true, nos permiten afirmar que una variable es de un tipo determinado. Veámoslo con un ejemplo:

const isPost = ( el: Element ): el is Post =>
  undefined !== ( el as Post ).title;
const isTask = ( el: Element ): el is Task =>
  undefined !== ( el as Task ).task;

Las funciones isPost e isTask reciben como parámetro un objeto de tipo Element y nos dicen si dicho elemento es un Post o una Task, respectivamente. Para ello, aprovechan el hecho de que hay atributos que solo un Post o una Task deben tener y, por lo tanto, si el elemento el tiene alguno de esos atributos, entonces podemos deducir su tipo.

Una vez tenemos definidas estas funciones, podemos hacer cosas como la siguiente:

function stringify( el: Element ): string {
  return isPost( el ) ? el.title : el.task;
}

donde usamos la guardia isPost para comprobar si el elemento era un Post. Si lo es, devolvemos su title; si no, TypeScript sabe que la única otra opción es que el sea una Task y, por lo tanto, nos permite consultar su atributo task sin darnos errores de ningún tipo.

Y ahora, ¿qué?

Si te soy sincero, la entrada de hoy ha sido un poco más larga de lo que esperaba… aunque creo que es una muy buena introducción a la teoría de tipos que necesitas conocer para poder modelar correctamente tus plugins o aplicaciones. En concreto, hoy hemos aprendido cómo podemos tipar nuestro código para explicitar qué es cada cosa y hemos visto cómo saber en todo momento con qué tipo de dato estamos trabajando.

La semana que viene veremos cómo todo esto nos permite «hacer imposibles los estados imposibles» y por qué esta frase, que quizás ahora suena un poco confusa, es la clave para escribir mejor código. ¡Nos vemos en una semanita!

Imagen destacada de Kelly Sikkema 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.