Uso avanzado de TypeScript en un ejemplo real (parte 2)

Publicada en WordPress.

Mira nuestro vídeo

Existe una versión mejor de tu web

Comparte este artículo

Una semana más seguimos con nuestra pequeña serie de entradas sobre TypeScript. Si te has perdido las anteriores, puedes verlas siguiendo estos enlaces: aquí tienes la introducción inicial a TypeScript que hicismos el pasado mes y aquí tienes la primera parte de este tutorial, donde te explico el ejemplo JavaScript con el que estamos trabajando y los pasos que seguimos para (empezar a) mejorarlo.

Hoy vamos a rematar nuestro ejemplo completando todo aquello que dejamos por hacer. En concreto, primero veremos cómo crear tipos que sean versiones parciales de otros tipos existentes. Luego veremos cómo tipar correctamente las acciones de un almacén Redux usando uniones de tipos y qué ventajas extra nos aporta esta solución. Finalmente, te explicaré cómo crear una función polimórfica cuyo tipo de retorno dependa de los parámetros de entrada.

Un breve repaso a lo que hicimos anteriormente…

En la primera parte del tutorial presentamos como ejemplo (parte de) un almacén Redux que sacamos de Nelio Content. De entrada, ese ejemplo era código JavaScript pelado. Durante esa entrada fuimos definiendo algunos de los tipos necesarios para mejorar la robustez e inteligibilidad del código original. Así, por ejemplo, definimos los siguientes tipos:

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

los cuales nos permiten entender, de un vistazo, el tipo de información con la que trabaja nuestro almacén. En concreto, podemos ver que el estado de nuestra aplicación almacena dos cosas: una lista de posts (los cuales tenemos indexados a través de su PostId) y una lista llamada days que, aparentemente, nos dice qué entradas hay en un cierto día. También podemos ver qué atributos tiene un Post y su tipo.

Una vez definidos estos tipos, fuimos editando todas las funciones de nuestro ejemplo para que los usaran. Por ejemplo, las cabeceras de algunas de las funciones pasaron de ser código JavaScript «opaco»:

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

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

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

a ser código TypeScript autoexplicativo:

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

Por ejemplo, la función getPostsInDay es un muy buen ejemplo de hasta qué punto TypeScript nos permite mejorar. Si ves la versión JavaScript, realmente no sabes qué va a devolver esa función. Leyendo el nombre da la sensación que devuelve una lista de entradas… pero si echamos un vistazo al código fuente, verás que, en realidad, devuelve una lista de identificadores de entrada. Aunque esto lo podríamos corregir poniendo un nombre más largo y explícito (getIdsOfPostsInDay, por ejemplo), es algo que se resuelve de forma sencillísima cuando vemos el tipo de retorno en TypeScript: PostId[].

Pues bien, tal y como puedes ver en el código anterior, aún tenemos trabajo por hacer. En concreto, necesitamos tipar el atributo attributes de la función updatePost y necesitamos definir la forma que tendrán las acciones de nuestro almacén (fíjate que en reducer, el atributo action ahora mismo es de tipo any).

Cómo tipar un objeto cuyos atributos son un subconjunto de otro objeto

Empecemos por algo sencillito, para entrar en calor. La función updatePost genera una acción que nos permite, dado un cierto identificador de post, actualizar uno o más atributos. Este es el detalle de la susodicha función:

function updatePost( postId: PostId, attributes: any ): any {
  return {
    type: 'UPDATE_POST',
    postId,
    attributes,
  };
}

y aquí vemos la parte relevante del reductor que lo usa:

function reducer( state: State, action: any ): State {
  // ...
  switch ( action.type ) {
    // ...
    case 'UPDATE_POST':
      if ( ! postIds.includes( action.postId ) ) {
        return state;
      }
      const post = {
         ...state.posts[ action.postId ],
         ...action.attributes,
      };
      return { ... };
  }
  // ...
}

donde empezamos comprobando que existe una entrada con ese postId y, si está, la actualizamos mezclando los atributos originales que hay en el estado con los que nos llegan en la acción.

Ahora la pregunta es: ¿qué tipo tiene attributes en nuestra acción? Sabemos que lo que queremos meter ahí es un objeto con atributos que nos permitan actualizar los valores de una entrada, luego parece lógico que attributes sea de tipo Post para poder mezclar una entrada existente con sus nuevos atributos:

type UpdatePostAction = {
  type: 'UPDATE_POST';
  postId: number;
  attributes: Post;
};

pero si intentamos usar esto veremos que no funciona:

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

porque los atributos no son una entrada entera, sino que solo contiene los atributos que queremos actualizar. Para solucionar este problema, basta con usar el tipo de utilidad Partial que nos ofrece TypeScript:

type UpdatePostAction = {
  type: 'UPDATE_POST';
  postId: number;
  attributes: Partial<Post>;
};

¡y ya funciona!

Filtrando atributos de forma explícita

Si te fijas un poco en el código anterior, verás que hay una posible fuente de errores que no estamos controlando. Cuando generamos la acción le pasamos el identificador de la entrada que queremos actualizar y los atributos que queremos cambiar. Una vez tenemos la acción lista, el reductor se encarga de sobrescribir la entrada existente con los nuevos valores:

const post = {
  ...state.posts[ action.postId ],
  ...action.attributes,
};

¿Ves ya qué puede estar fallando? En efecto, es posible que la acción que creemos tenga un identificador de entrada x en el atributo postId y otro identificador distinto y en la lista de atributos:

const action: UpdatePostAction = {
  type: 'UPDATE_POST',
  postId: 1,
  attributes: {
    id: 2,
    author: 'Toni',
  },
};

lo cual TypeScript da por bueno, aunque nosotros sepamos que está mal. Está claro que en este caso hemos sido poco precisos a la hora de programar nuestras acciones y nuestro reductor. Pero ahora la pregunta es: ¿podemos arreglarlo?

Una posible solución a este problema es modificar nuestra acción para que no exista la posibilidad de tener dos identificadores distintos. Así, por ejemplo, si eliminamos el atributo postId de la acción, podemos meterlo siempre dentro de la lista de 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,
    },
  };
}

y luego modificar el reductor para que busque la entrada en el estado usando action.attributes.id en lugar de action.postId.

Por desgracia, esta solución no es ideal. Sabemos que funciona porque estamos generando la acción con el identificador de la entrada dentro de attributes, pero si vemos el tipo UpdatePostAction no tenemos ninguna garantía de que attributes realmente incluya el atributo id. Es decir, si en el futuro alguien modificara la función updatePost y no pusiera el postId dentro de attributes, la acción resultante sería válida según TypeScript pero nuestro código no funcionaría:

const workingAction: UpdatePostAction = {
  type: 'UPDATE_POST',
  attributes: {
    id: 1,
    author: 'Toni',
  },
};

const failingAction: UpdatePostAction = {
  type: 'UPDATE_POST',
  attributes: {
    author: 'Toni',
  },
};

Así pues, si queremos que TypeScript nos proteja, debemos ser lo más precisos posible a la hora de especificar qué forma esperamos que tenga nuestra estructura de datos. Y en este caso tenemos dos opciones:

  1. Si usamos el atributo postId en action, debemos indicarle a TypeScript que attributes no puede tener el atributo id.
  2. Si no usamos el atributo postId en action, debemos indicarle a TypeScript que en attributes debe haber sí o sí un atributo id de tipo PostId.

Para la primera solución podemos usar otro tipo de utilidad de TypeScript, Omit, que nos permite «quitar» atributos concretos de un tipo que tengamos definido:

type UpdatePostAction = {
  type: 'UPDATE_POST';
  postId: PostId,
  attributes: Partial< Omit<Post, 'id'> >;
};

lo cual funciona como esperábamos:

const workingAction: UpdatePostAction = {
  type: 'UPDATE_POST',
  postId: 1,
  attributes: {
    author: 'Toni',
  },
};

const failingAction: UpdatePostAction = {
  type: 'UPDATE_POST',
  postId: 1,
  attributes: {
    id: 1,
    author: 'Toni',
  },
};

Para la segunda opción, basta con añadir explícitamente el atributo id al resultado de aplicar el tipo de utilidad Partial:

type UpdatePostAction = {
  type: 'UPDATE_POST';
  attributes: Partial<Post> & { id: PostId };
};

lo cual, de nuevo, nos da el resultado esperado:

const workingAction: UpdatePostAction = {
  type: 'UPDATE_POST',
  attributes: {
    id: 1,
    author: 'Toni',
  },
};

const failingAction: UpdatePostAction = {
  type: 'UPDATE_POST',
  attributes: {
    author: 'Toni',
  },
};

Unión de tipos

En la sección anterior ya hemos visto cómo tipar una de las dos acciones. Básicamente, consiste en definir un nuevo tipo siguiendo el mismo patrón que aplicamos cuando definimos los tipos Post o State. Así pues, hagamos lo propio con la segunda acción. Sabiendo que receiveNewPost tiene esta pinta:

function receiveNewPost( post: Post ): any {
  return {
    type: 'RECEIVE_NEW_POST',
    post,
  };
}

su tipo (o el tipo de retorno de la función, si lo prefieres) tiene que ser el siguiente:

type ReceiveNewPostAction = {
  type: 'RECEIVE_NEW_POST';
  post: Post;
};

Ahora bien, si vamos a nuestro reductor veremos que uno de los parámetros de entrada es una acción, cuyo tipo aún no hemos especificado:

function reducer( state: State, action: any ): State { ... }

El problema es que tenemos dos tipos de acciones diferentes: UpdatePostAction y ReceiveNewPostAction. ¿Qué tenemos que poner, pues, en action?

En estos casos, la solución pasa por crear una unión de tipos. Una unión de tipos es un tipo cuyos valores pueden ser de cualquiera de los tipos especificados en esa unión. Esto, que puede sonar muy confuso, es tan tonto como esto:

type Action = UpdatePostAction | ReceiveNewPostAction;

Es decir, estamos diciendo que cualquier variable o argumento que marquemos como Action podrá ser, o bien de tipo UpdatePostAction, o bien de tipo ReceiveNewPostAction. Sabiendo esto, ya podemos arreglar la cabecera de nuestro reductor:

function reducer( state: State, action: Action ): State { ... }

y, con esto, ver el resultado de todo el código bien tipado.

Cómo una unión de tipos nos permite eliminar los casos por defecto

Si has abierto el último enlace, habrás visto que al código bien tipado aún contiene un error. Según TypeScript, nuestro reductor contiene código que nunca se va a ejecutar:

function reducer( state: State, action: Action ): State {
  // ...
  switch ( action.type ) {
    case 'RECEIVE_NEW_POST': // ...
    case 'UPDATE_POST': // ...
  }
  return state; //Error! Unreachable code
}

¿Qué está pasando aquí? Muy sencillo. La unión de tipos Action que hemos creado es discriminada. Una unión de tipos es discriminada si todos los tipos de la unión tienen un atributo en común cuyo valor es conocido de antemano y único para cada uno de ellos, pues podemos usar dicho atributo para discriminar el tipo concreto que tenemos entre manos.

En nuestro caso, los dos tipos de Action tienen un atributo type cuyos valores son RECEIVE_NEW_POST para ReceiveNewPostAction y UPDATE_POST para UpdatePostAction. Como sabemos que una Action es, necesariamente, de uno de los dos tipos, las dos ramas de nuestro switch cubren todas las posibilidades: o bien action.type es RECEIVE_NEW_POST o bien es UPDATE_POST. Por lo tanto, el return final nunca se ejecutará.

Supongamos, pues, que eliminamos ese return para eliminar este error. ¿Ganamos algo, más allá de quitar código innecesario? La respuesta es sí. Si ahora añadimos un nuevo tipo de acción en nuestro código:

type Action =
  | UpdatePostAction
  | ReceiveNewPostAction
  | NewFeatureAction;

type NewFeatureAction = {
  type: 'NEW_FEATURE';
  // ...
};

de repente el switch de nuestro reductor ya no cubre todos los escenarios posibles:

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
}

con lo que es posible que se devuelva undefined cuando entra una acción de tipo NEW_FEATURE y, por lo tanto, es posible que el reductor no devuelva un estado State válido. Typescript, en este caso detecta el error y nos avisa de que tenemos que completar el switch.

Funciones polimórficas con tipos de retorno variables

Si has llegado hasta aquí, felicidades: has aprendido todo lo necesario para mejorar el código fuente de tus aplicaciones JavaScript usando TypeScript. Y, como premio, voy a compartir contigo un «problema» con el que me encontré hace unos días y su solución. Porque sí, amigo, esto de TypeScript es un mundo complejo y fascinante.

Al principio de toda esta aventura te comentaba que uno de los selectores que tenemos es getPostInDay, el cual nos devuelve la lista de entradas que hay en un cierto día. Bueno, de hecho no devuelve las entradas, sino sus ID:

function getPostsInDay( state: State, day: Day ): PostId[] {
  return state.days[ day ] ?? [];
}

Pues bien, imagina el siguiente escenario. Supón que quieres que esta función pueda devolverte o bien una lista de IDs o bien una lista de entradas, según lo que le pases en un parámetro de entrada:

const ids: PostId[] = getPostsInDay( '2020-10-01', 'id' );
const posts: Post[] = getPostsInDay( '2020-10-01', 'all' );

¿Cómo podemos especificar esto? Pues muy fácil, definiendo una función polimórfica cuyo resultado depende de los parámetros de entrada. En este caso, está claro que queremos dos tipos de función: una que devuelve una lista de PostId si uno de los atributos es el string id y otra que devuelve una lista de Post si ese mismo atributo es el string all, así que vamos a crear ambas versiones:

function getPostsInDay( state: State, day: Day, mode: 'id' ): PostId[] {
  // ...
}
function getPostsInDay( state: State, day: Day, mode: 'all' ): Post[] {
  // ...
}

El problema es que si intentas hacer lo anterior en TypeScript, verás que te da un error. En concreto, te dice que tienes la implementación de una función duplicada.

Viendo esto, la tentación es crear una única función como siempre y tiparla de la siguiente forma:

function getPostsInDay( state: State, day: Day, mode: 'id' | 'all' = 'id' ): PostId[] | Post[] {
  if ( 'id' === mode ) {
    return state.days[ day ] ?? [];
  }
  return [];
}

pero esto no es correcto. Lo que nos está diciendo esta cabecera es que «el parámetro mode puede ser id o all» y que «el resultado puede ser una lista de PostId o una lista de Post»; en ningún lado estamos especificando que si metemos mode id obtendremos una lista de PostId y que si metemos all tendremos será de Post. Es por ello que esto:

const state: State = { posts: {}, days: {} };
const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' );
const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );

no funciona como queremos.

La forma de solucionarlo es una mezcla de la intuición inicial con la implementación segunda:

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

Es decir, justo antes de la cabecera de la función que tiene la implementación, definimos las diferentes formas que puede tener la misma. De esta forma, queda absolutamente claro qué esperamos como resultado según qué le pasamos como parámetro de entrada.

El resultado es que esto funciona:

const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'id' );
const posts: Post[] = getPostsInDay( state, '2020-10-01', 'all' );

Y esto da errores:

const ids: PostId[] = getPostsInDay( state, '2020-10-01', 'all' );
const posts: Post[] = getPostsInDay( state, '2020-10-01', 'id' );

Conclusiones

En esta serie de entradas hemos visto qué es TypeScript y cómo podemos aplicarlo en nuestros proyectos. Los tipos nos ayudan a documentar mejor el código añadiendo detalles semánticos que, sin ellos, quedan diluídos en el código fuente o en los nombres de nuestras variables y funciones. Además, los tipos también añaden una capa de seguridad extra, puesto que el compilador de TypeScript se encarga de validar que todas las piezas encajan y tienen la forma y estructura que esperamos.

Llegados a este punto ya tienes todas las herramientas necesarias para llevar la calidad de tu trabajo al siguiente nivel. ¡Mucha suerte en esta nueva aventura!

Imagen destacada de Mike Kenneally 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.