A man washing his hands

Los datos que utilizas en tu aplicación pueden proceder de muchas fuentes, algunas más fiables que otras. Pero independientemente de su origen y de quién los haya producido, es tu responsabilidad validarlos y sanearlos todos antes de usarlos.

¿Cómo? ¿No sabes de qué te hablo? Tranqui, la documentación de WordPress nos da la respuesta:

La validación de datos es el proceso de contrastarlos contra un patrón (o patrones) predefinidos con el objetivo de darlos por buenos o rechazarlos. Por otro lado, sanear los datos de entrada es el proceso de asegurar, limpiar y filtrar los datos de entrada. Se prefiere la validación al saneamiento porque la validación es más específica. Pero si no puedes «validar», opta por «sanear».

Documentos para desarrolladores de WordPress

La validación y el saneamiento de datos suelen discutirse siempre en el lado del servidor de los plugins de WordPress. Así, al recibir los datos enviados por un usuario, el desarrollador del plugin tiene que validar su nonce, asegurarse de que todos los campos están definidos correctamente, etc. Y es que, al fin y al cabo, es en el servidor donde acaban almacenándose todos los datos, sensibles o no, y por lo tanto, es primordial asegurarse de que están limpios y son seguros.

Ahora bien, a medida que avanzamos hacia plataformas más reactivas implementadas con JavaScript, validar y sanear los datos también se vuelve importante en el lado del cliente. Y de eso me gustaría hablar brevemente hoy.

Obteniendo datos en JavaScript

Consideremos un ejemplo extraído del código fuente de Nelio Content. El plugin tiene tareas editoriales, una función que básicamente te permite asignar tareas a los miembros de tu equipo para que todos sepan quién tiene que hacer qué y cuándo. Así es como se ven:

Captura de pantalla de algunas tareas editoriales tal como se presentan en la pantalla del editor de publicaciones
Las tareas editoriales realizan un seguimiento de qué y cuándo debe ser realizado por quién.

Las Tareas Editoriales se almacenan en la nube de Nelio. Esto significa que, por ejemplo, cuando cambia el estado de una tarea, nuestro plugin tiene que lanzar una petición PUT a nuestra nube para realizar la actualización. La respuesta que obtenemos de la nube es la tarea actualizada. Aquí está el código fuente:

export async function markTaskAsCompleted( taskId, completed ) {
  const task = select( NC_DATA ).getTask( taskId );
  if ( ! task ) {
    return;
  }
  try {
    const updatedTask = await apiFetch( {
      url: `https://api.neliocontent.com/.../${ taskId }`,
      method: 'PUT',
      headers: { Authorization: '...' },
      data: { ...task, completed },
    } );
    dispatch( NC_DATA ).receiveTasks( updatedTask );
  } catch ( e ) {
    showError( e );
  }
}
Lenguaje del código: JavaScript (javascript)

Lo que básicamente se traduce en lo siguiente:

  • Definimos una función llamada markTaskAsCompleted que toma dos argumentos: un taskId y completed (si se ha completado o no).
  • Buscamos la task en nuestro store Redux. Si no la encontramos, no hay ninguna task cuyo estado de finalización pueda actualizarse y nos vamos.
  • Si la tenemos, lanzamos una solicitud PUT asíncrona a la nube de Nelio para actualizar la tarea. Esta solicitud envía la tarea con el nuevo valor completed y devuelve como resultado la updatedTask.
  • Una vez que recibimos la respuesta, actualizamos con ella nuestro store Redux.

Pero, ¿qué pasa si la respuesta que recibimos del servidor no es una EditorialTask como la que esperábamos? ¿Y si algo ha cambiado? 🤔

Obteniendo datos en TypeScript

Parece que tenemos un problema de tipos, con lo que quizás TypeScript nos pueda ayudar… Vamos, pues, a estudiar el problema desde la perspectiva de TypeScript:

// Type imported from somewhere else
type EditorialTask = {
  readonly id: Uuid;
  readonly task: string;
  readonly completed: boolean;
  readonly postId?: PostId;
  // ...
};
export async function markTaskAsCompleted(
  taskId: Uuid,
  completed: boolean
): Promise< void > {
  const task = select( NC_DATA ).getTask( taskId );
  if ( ! task ) {
    return;
  }
  try {
    const updatedTask = await apiFetch< EditorialTask >( {
      url: `https://api.neliocontent.com/.../${ taskId }`,
      method: 'PUT',
      headers: { Authorization: '...' },
      data: { ...task, completed },
    } );
    dispatch( NC_DATA ).receiveTasks( updatedTask );
  } catch ( e ) {
    showError( e );
  }
}Lenguaje del código: JavaScript (javascript)

Esta reescritura ha sido extremadamente fácil (suponiendo que todas las funciones que usamos también estén escritas en TypeScript). Todo lo que hemos hecho ha sido añadir algunos tipos aquí y allá:

  • taskId debe ser Uuid
  • completed debe ser boolean
  • Nuestra función asíncrona devuelve una Promise
  • Indicamos manualmente que esta invocación a apiFetch devolverá una EditorialTask

Espera, ¿qué? ¿«Indicamos manualmente»? Sí, mira. Si echas un vistazo a la definición de apiFetch en GitHub, verás varias cosas interesantes:

/**
 * @template T
 * @param {import('./types').APIFetchOptions} options
 * @return {Promise<T>} A promise ...
 */
function apiFetch( options ) {
  ...
}Lenguaje del código: PHP (php)

Lo primero es que apiFetch está (en el momento de escribir esta entrada) escrita en JavaScript. Y, lo segundo, es que su definición de tipos está en comentarios JSDoc. Si lo pasamos a TypeScript tenemos:

import type { APIFetchOptions } from './types';
function apiFetch< T >( options: APIFetchOptions ): Promise< T > {
  ...
}Lenguaje del código: JavaScript (javascript)

Y, ahora sí, puedes ver claramente que se trata de función genérica, es decir, una función que toma un tipo T opcional como argumento. Este tipo T podemos luego usarlo para restringir algunos de sus argumentos y/o definir el tipo de resultado que dará la función.

Con esta definición, vemos que podemos hacer cosas absurdas como las siguientes :

declare const args: APIFetchOptions;
const n = apiFetch< number >( args );
//    ^? n: Promise< number >
const b = apiFetch< boolean >( args );
//    ^? b: Promise< boolean >
const t = apiFetch< EditorialTask )( args );
//    ^? t: Promise< EditorialTask >Lenguaje del código: PHP (php)

Es decir, a pesar de estar invocando la función con los mismos argumentos siempre, resulta que puedo decirle a TypeScript que lo mismo me devuelve un número, que un boolean, que una tarea editorial.

En otras palabras, básicamente le estamos diciendo al compilador de TypeScript «confía en mí, el resultado de la llamada será de este tipo, que yo controlo».

¿Cómo podemos arreglar esto? Bueno, simplemente no afirmes nada sobre el resultado de apiFetch :

export async function markTaskAsCompleted(
  taskId: Uuid,
  completed: boolean
): Promise< void > {
  const task = select( NC_DATA ).getTask( taskId );
  if ( ! task ) {
    return;
  }
  try {
    const updatedTask = await apiFetch( {
      url: `https://api.neliocontent.com/.../${ taskId }`,
      method: 'PUT',
      headers: { Authorization: '...' },
      data: { ...task, completed },
    } );
    dispatch( NC_DATA ).receiveTasks( updatedTask );
  } catch ( e ) {
    showError( e );
  }
}Lenguaje del código: JavaScript (javascript)

y deja que updatedTask sea unknown para que TypeScript se queje cuando intentas usar una variable unknown en receiveTasks :

E: Argument of type 'unknown' is not assignable to parameter of
type EditorialTask | readonly EditorialTask[]Lenguaje del código: JavaScript (javascript)

Una vez hecho esto, es nuestra responsabilidad implementar algo que verifique si la respuesta que hemos recibido es del tipo apropiado. Con tipos simples como boolean o number, esta comprobación es tan simple como llamar a typeof x y ver si es un 'boolean' o un 'number'. Pero con tipos más complejos necesitamos un predicado de tipo :

const isTask = ( t: unknown ): t is EditorialTask =>
  !! t &&
  typeof t === 'object' &&
  'id' in t &&
  isUuid( t.id ) &&
  // ...Lenguaje del código: JavaScript (javascript)

O sea, una función que compruebe si lo que recibimos tiene la forma que espramos. Y, como puedes imaginar, cuanto más complejo sea el tipo, más complicado será ese predicado. O, bueno, así era hasta que apareció Zod…

Zod

Para que nuestro código TypeScript sea seguro necesitamos dos cosas. Primero, necesitamos el tipo EditorialTask que iremos usando en todas las declaraciones de funciones, stores, etc, que tengamos en nuestro código. Segundo, necesitamos implementar un predicado de tipo que valide si una variable unknown es o no una EditorialTask. Y no sé a ti, pero a mí me parece que esto debería ser más fácil… especialmente cuando la parte de la función de validación, que es horrible de implementar.

Zod es una validación de esquema de TypeScript con inferencia de tipos estática. En términos sencillos, significa que Zod se encarga de las dos cosas que teníamos que hacer antes:

  1. Definir un esquema con la forma que se supone que deben tener tus datos y que nos permitirá validar si los nuestros datos son como esperamos que sean
  2. Inferir automáticamente el tipo de TypeScript estático a partir del esquema

Reescribamos nuestro ejemplo

Sinceramente, creo que la forma más fácil de entender todas las ventajas que Zod nos proporciona a los desarrolladores es a través del ejemplo que hemos estado discutiendo. Veamos cómo quedaría todo si lo pasamos a Zod:

import z from 'zod';
export const editorialTaskSchema = z.object( {
  id: z.string().uuid(),
  task: z.string().min( 1 ),
  completed: z.boolean(),
  postId: z.number().positive().optional(),
} );
export type EditorialTask = z.infer< typeof editorialTaskSchema >;Lenguaje del código: JavaScript (javascript)

¡Fíjate qué fácil y bonito queda todo! En esencia:

  1. Comenzamos definiendo un nuevo esquema usando Zod. En este caso particular, el esquema es un objeto, por lo que usamos z.object.
  2. z.object recibe como parámetro un objeto que, en nuestro caso, describe la forma de una EditorialTask. Cada propiedad de este objeto es, a su vez, un esquema Zod:
    • id tiene que ser un string y, además, le indicamos a Zod que no nos vale un string cualquiera; tiene que ser un uuid válido.
    • task también es un string, y en este caso lo único que requerimos es que no esté vacío (es decir, que su longitud min sea 1).
    • completed es un boolean.
    • postId es un número positivo opcional.
  3. Por último, dejamos que Zod infiera (infer) el tipo EditorialTask de TypeScript a través del typeof nuestro esquema.

¡Y ya está! Ahora tenemos exactamente las mismas piezas que teníamos antes, pero el resultado es más limpio e inteligible. Así es como lo utilizaríamos:

import { editorialTaskSchema } from '@nelio-content/schemas';
export async function markTaskAsCompleted(
  taskId: Uuid,
  completed: boolean
): Promise< void > {
  const task = select( NC_DATA ).getTask( taskId );
  if ( ! task ) {
    return;
  }
  try {
    const result = await apiFetch( {
      url: `https://api.neliocontent.com/.../${ taskId }`,
      method: 'PUT',
      headers: { Authorization: '...' },
      data: { ...task, completed },
    } );
    const updatedTask = editorialTaskSchema.parse( result );
    dispatch( NC_DATA ).receiveTasks( updatedTask );
  } catch ( e ) {
    showError( e );
  }
}Lenguaje del código: JavaScript (javascript)

Fácil, ¿verdad? Simplemente necesitamos parsear el resultado; si es válido, obtendremos la updatedTask como resultado y, si no lo es, provocará una excepción que recogemos a través del catch. (Si no quieres trabajar con excepciones, puedes utilizar safeParse en su lugar).

Y eso es todo, gente. Hay mucha documentación interesante en el sitio web de Zod. Te sugiero encarecidamente que le eches un vistazo y consideres la posibilidad de usarlo en tus proyectos. Te hará la vida mucho más fácil y serás más feliz, sin duda.

¡Hasta la próxima! on Unsplash.

Imagen destacada de Fran Jacquier en Unsplash.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

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

Tus datos personales se almacenarán en SiteGround y serán usados por Nelio Software con el único objetivo de publicar tu comentario aquí. Con el envío de este comentario, nos das el consentimiento expreso para ello. Escríbenos para acceder, rectificar, limitar o eliminar tus datos personales.