Capítulo 4. Diseño tipográfico

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Enséñame tus organigramas y oculta tus tablas, y seguiré desconcertado. Enséñame tus tablas, y normalmente no necesitaré tus organigramas; serán obvios.

Fred Brooks, Mes del Hombre Mítico

El lenguaje de la cita de Fred Brooks es anticuado, pero el sentimiento sigue siendo cierto: el código es difícil de entender si no puedes ver los datos o tipos de datos sobre los que opera. Esta es una de las grandes ventajas de un sistema de tipos: al escribir los tipos, los haces visibles para los lectores de tu código. Y esto hace que tu código sea comprensible.

Otros capítulos cubren los entresijos de los tipos TypeScript: cómo utilizarlos, cómo inferirlos y cómo escribir declaraciones con ellos. Este capítulo trata del diseño de los propios tipos. Todos los ejemplos de este capítulo están escritos pensando en TypeScript, pero la mayoría de las ideas son de aplicación más general.

Si escribes bien tus tipos, con un poco de suerte tus diagramas de flujo también serán obvios.

Tema 28: Prefiere tipos que siempre representen estados válidos

Si diseñas bien tus tipos, tu código debería ser sencillo de escribir. Pero si diseñas mal tus tipos, no te salvará ningún tipo de ingenio o documentación. Tu código será confuso y propenso a los errores.

Una clave para un diseño tipográfico eficaz es crear tipos que sólo puedan representar un estado válido. Este artículo recorre algunos ejemplos de cómo esto puede salir mal y te muestra cómo solucionarlo.

Supón que estás construyendo una aplicación web que te permite seleccionar una página, cargar el contenido de esa página y, a continuación, mostrarlo. Podrías escribir el estado así

interface State {
  pageText: string;
  isLoading: boolean;
  error?: string;
}

Cuando escribas el código para representar la página, deberás tener en cuenta todos estos campos:

function renderPage(state: State) {
  if (state.error) {
    return `Error! Unable to load ${currentPage}: ${state.error}`;
  } else if (state.isLoading) {
    return `Loading ${currentPage}...`;
  }
  return `<h1>${currentPage}</h1>\n${state.pageText}`;
}

¿Pero esto es correcto? ¿Y si isLoading y error están ambos fijados? ¿Qué significaría eso? ¿Es mejor mostrar el mensaje de carga o el mensaje de error? Es difícil decirlo. No hay suficiente información disponible.

¿Y si estás escribiendo una función changePage? Aquí tienes un intento:

async function changePage(state: State, newPage: string) {
  state.isLoading = true;
  try {
    const response = await fetch(getUrlForPage(newPage));
    if (!response.ok) {
      throw new Error(`Unable to load ${newPage}: ${response.statusText}`);
    }
    const text = await response.text();
    state.isLoading = false;
    state.pageText = text;
  } catch (e) {
    state.error = '' + e;
  }
}

¡Hay muchos problemas con esto! He aquí algunos:

  • Olvidamos poner state.isLoading en false en el caso de error.

  • No hemos borrado state.error, por lo que si la solicitud anterior falló, seguirás viendo ese mensaje de error en lugar de un mensaje de carga.

  • Si el usuario vuelve a cambiar de página mientras ésta se está cargando, quién sabe lo que ocurrirá. Puede que vea una página nueva y luego un error, o la primera página y no la segunda, dependiendo del orden en que vuelvan las respuestas.

El problema es que el estado incluye muy poca información (¿qué petición ha fallado? ¿cuál se está cargando?) y demasiada: el tipo State permite establecer tanto isLoading como error, aunque esto represente un estado no válido. Esto hace que tanto render() y changePage() imposibles de implementar bien.

Aquí tienes una forma mejor de representar el estado de la aplicación:

interface RequestPending {
  state: 'pending';
}
interface RequestError {
  state: 'error';
  error: string;
}
interface RequestSuccess {
  state: 'ok';
  pageText: string;
}
type RequestState = RequestPending | RequestError | RequestSuccess;

interface State {
  currentPage: string;
  requests: {[page: string]: RequestState};
}

Utiliza una unión etiquetada (también conocida como "unión discriminada") para modelar explícitamente los distintos estados en que puede encontrarse una solicitud de red. Esta versión del estado es de tres a cuatro veces más larga, pero tiene la enorme ventaja de no admitir estados no válidos. La página actual se modela explícitamente, al igual que el estado de cada solicitud que emitas. Como resultado, las funciones renderPage y changePage son fáciles de implementar:

function renderPage(state: State) {
  const {currentPage} = state;
  const requestState = state.requests[currentPage];
  switch (requestState.state) {
    case 'pending':
      return `Loading ${currentPage}...`;
    case 'error':
      return `Error! Unable to load ${currentPage}: ${requestState.error}`;
    case 'ok':
      return `<h1>${currentPage}</h1>\n${requestState.pageText}`;
  }
}

async function changePage(state: State, newPage: string) {
  state.requests[newPage] = {state: 'pending'};
  state.currentPage = newPage;
  try {
    const response = await fetch(getUrlForPage(newPage));
    if (!response.ok) {
      throw new Error(`Unable to load ${newPage}: ${response.statusText}`);
    }
    const pageText = await response.text();
    state.requests[newPage] = {state: 'ok', pageText};
  } catch (e) {
    state.requests[newPage] = {state: 'error', error: '' + e};
  }
}

La ambigüedad de la primera implementación ha desaparecido por completo: está claro cuál es la página actual, y cada solicitud se encuentra exactamente en un estado. Si el usuario cambia de página después de emitir una solicitud, tampoco hay problema. La antigua solicitud sigue completándose, pero no afecta a la interfaz de usuario.

Para un ejemplo más sencillo pero más terrible, considera el destino del vuelo 447 de Air France, un Airbus 330 que desapareció sobre el Atlántico el 1 de junio de 2009. El Airbus era un avión fly-by-wire, lo que significa que las entradas de control de los pilotos pasaban por un sistema informático antes de afectar a las superficies físicas de control del avión. Tras el accidente se plantearon muchas cuestiones sobre la conveniencia de confiar en los ordenadores para tomar decisiones de vida o muerte. Dos años después, cuando se recuperaron las grabadoras de la caja negra, revelaron muchos factores que condujeron al accidente. Pero uno de los principales fue el mal diseño del estado.

La cabina del Airbus 330 tenía un conjunto separado de mandos para el piloto y el copiloto. Los "side sticks" controlaban el ángulo de ataque. Tirando hacia atrás, el avión ascendía, mientras que empujando hacia delante caía en picado. El Airbus 330 utilizaba un sistema llamado modo de "doble entrada", que permitía que los dos sticks laterales se movieran independientemente. He aquí cómo podrías modelar su estado en TypeScript:

interface CockpitControls {
  /** Angle of the left side stick in degrees, 0 = neutral, + = forward */
  leftSideStick: number;
  /** Angle of the right side stick in degrees, 0 = neutral, + = forward */
  rightSideStick: number;
}

Supongamos que te dan esta estructura de datos y te piden que escribas una función getStickSetting que calcule el ajuste actual de la palanca. ¿Cómo lo harías?

Una forma sería asumir que el piloto (que se sienta a la izquierda) tiene el control:

function getStickSetting(controls: CockpitControls) {
  return controls.leftSideStick;
}

Pero, ¿y si el copiloto ha tomado el control? Tal vez debas utilizar el stick que esté más alejado de cero:

function getStickSetting(controls: CockpitControls) {
  const {leftSideStick, rightSideStick} = controls;
  if (leftSideStick === 0) {
    return rightSideStick;
  }
  return leftSideStick;
}

Pero hay un problema con esta implementación: sólo podemos estar seguros de devolver el ajuste izquierdo si el derecho es neutro. Así que deberías comprobarlo:

function getStickSetting(controls: CockpitControls) {
  const {leftSideStick, rightSideStick} = controls;
  if (leftSideStick === 0) {
    return rightSideStick;
  } else if (rightSideStick === 0) {
    return leftSideStick;
  }
  // ???
}

¿Qué haces si ambos son distintos de cero? Esperemos que sean más o menos iguales, en cuyo caso podrías simplemente promediarlos:

function getStickSetting(controls: CockpitControls) {
  const {leftSideStick, rightSideStick} = controls;
  if (leftSideStick === 0) {
    return rightSideStick;
  } else if (rightSideStick === 0) {
    return leftSideStick;
  }
  if (Math.abs(leftSideStick - rightSideStick) < 5) {
    return (leftSideStick + rightSideStick) / 2;
  }
  // ???
}

Pero, ¿y si no lo son? ¿Puedes lanzar un error? En realidad no: ¡los alerones tienen que estar colocados en algún ángulo!

En el Air France 447, el copiloto tiró silenciosamente hacia atrás del mando lateral cuando el avión entró en tormenta. Ganó altitud, pero acabó perdiendo velocidad y entró en pérdida, una situación en la que el avión se mueve demasiado despacio para generar sustentación de forma eficaz. Empezó a descender.

Para escapar de una entrada en pérdida, los pilotos están entrenados para empujar los mandos hacia delante para hacer que el avión caiga en picado y recupere velocidad. Esto es exactamente lo que hizo el piloto. Pero el copiloto seguía tirando silenciosamente hacia atrás de su mando lateral. Y la función del Airbus tenía este aspecto:

function getStickSetting(controls: CockpitControls) {
  return (controls.leftSideStick + controls.rightSideStick) / 2;
}

A pesar de que el piloto empujó la palanca completamente hacia delante, la media se quedó en nada. No tenía ni idea de por qué el avión no se sumergía. Cuando el copiloto reveló lo que había hecho, el avión había perdido demasiada altitud para recuperarse y se estrelló en el océano, matando a las 228 personas que iban a bordo.

La cuestión de todo esto es que ¡no hay una buena forma de implementar getStickSetting dada esa entrada! La función se ha configurado para que falle. En la mayoría de los aviones, los dos conjuntos de mandos están conectados mecánicamente. Si el copiloto tira hacia atrás, los mandos del piloto también lo harán. El estado de estos mandos es sencillo de expresar:

interface CockpitControls {
  /** Angle of the stick in degrees, 0 = neutral, + = forward */
  stickAngle: number;
}

Y ahora, como en la cita de Fred Brooks del principio del capítulo, nuestros diagramas de flujo son obvios. No necesitas en absoluto una función getStickSetting.

Cuando diseñes tus tipos, ten en cuenta qué valores incluyes y cuáles excluyes. Si sólo permites valores que representen estados válidos, tu código será más fácil de escribir y a TypeScript le resultará más fácil comprobarlo. Éste es un principio muy general, y varios de los otros puntos de este capítulo cubrirán manifestaciones específicas del mismo.

Cosas para recordar

  • Los tipos que representan estados válidos e inválidos pueden dar lugar a código confuso y propenso a errores.

  • Prefiere tipos que sólo representen estados válidos. Aunque sean más largos o difíciles de expresar, al final te ahorrarán tiempo y sufrimiento.

Tema 29: Sé liberal en lo que aceptas y estricto en lo que produces

Esta idea de se conoce como principio de robustez o Ley de Postel, en honor a Jon Postel, que la escribió en el contexto del TCP:

Las implementaciones TCP deben seguir un principio general de robustez: sé conservador en lo que haces, sé liberal en lo que aceptas de los demás.

Una regla similar se aplica a los contratos de las funciones. Está bien que tus funciones sean amplias en lo que aceptan como entradas, pero en general deben ser más específicas en lo que producen como salidas.

Por ejemplo, una API de mapeado 3D puede proporcionar un modo de posicionar la cámara y calcular una ventana gráfica para un cuadro delimitador:

declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): CameraOptions;

Es conveniente que el resultado de viewportForBounds se pueda pasar directamente a setCamera para posicionar la cámara.

Veamos las definiciones de estos tipos:

interface CameraOptions {
  center?: LngLat;
  zoom?: number;
  bearing?: number;
  pitch?: number;
}
type LngLat =
  { lng: number; lat: number; } |
  { lon: number; lat: number; } |
  [number, number];

Los campos de CameraOptions son todos opcionales porque puede que quieras establecer sólo el centro o el zoom sin cambiar el rumbo o el cabeceo. El tipo LngLat también hace que setCamera sea liberal en cuanto a lo que acepta: puedes pasar un objeto {lng, lat}, un objeto {lon, lat} o un par [lng, lat] si estás seguro de que tienes el orden correcto. Estas adaptaciones hacen que la función sea fácil de llamar.

La función viewportForBounds recoge otro tipo "liberal":

type LngLatBounds =
  {northeast: LngLat, southwest: LngLat} |
  [LngLat, LngLat] |
  [number, number, number, number];

Puedes especificar los límites utilizando esquinas con nombre, un par de lat/lngs, o una cuádruple tupla si estás seguro de haber acertado con el orden. Como LngLat ya admite tres formas, hay nada menos que 19 formas posibles para LngLatBounds. ¡Realmente liberal!

Ahora escribamos una función que ajuste la ventana gráfica para acomodar una Característica GeoJSON y almacene la nueva ventana gráfica en la URL (para una definición de calculateBoundingBox, consulta el Tema 31):

function focusOnFeature(f: Feature) {
  const bounds = calculateBoundingBox(f);
  const camera = viewportForBounds(bounds);
  setCamera(camera);
  const {center: {lat, lng}, zoom} = camera;
               // ~~~      Property 'lat' does not exist on type ...
               //      ~~~ Property 'lng' does not exist on type ...
  zoom;  // Type is number | undefined
  window.location.search = `?v=@${lat},${lng}z${zoom}`;
}

¡Vaya! Sólo existe la propiedad zoom, pero su tipo se infiere como number|undefined, lo que también es problemático. La cuestión es que la declaración de tipo de viewportForBounds indica que es liberal no sólo en lo que acepta, sino también en lo que produce. La única forma segura de utilizar el resultado camera es introducir una rama de código para cada componente del tipo de unión(Tema 22).

El tipo de retorno con muchas propiedades opcionales y tipos de unión hace que viewportForBounds sea difícil de utilizar. Su amplio tipo de parámetro es conveniente, pero su amplio tipo de retorno no lo es. Una API más cómoda sería estricta en lo que produce.

Una forma de hacerlo es distinguir un formato canónico para las coordenadas. Siguiendo la convención de JavaScript de distinguir entre "Array" y "Array-like"(Tema 16), puedes establecer una distinción entre LngLat y LngLatLike. También puedes distinguir entre un tipo Camera completamente definido y la versión parcial aceptada por setCamera:

interface LngLat { lng: number; lat: number; };
type LngLatLike = LngLat | { lon: number; lat: number; } | [number, number];

interface Camera {
  center: LngLat;
  zoom: number;
  bearing: number;
  pitch: number;
}
interface CameraOptions extends Omit<Partial<Camera>, 'center'> {
  center?: LngLatLike;
}
type LngLatBounds =
  {northeast: LngLatLike, southwest: LngLatLike} |
  [LngLatLike, LngLatLike] |
  [number, number, number, number];

declare function setCamera(camera: CameraOptions): void;
declare function viewportForBounds(bounds: LngLatBounds): Camera;

El tipo suelto CameraOptions adapta el tipo más estricto Camera (Elemento 14).

Utilizar Partial<Camera> como tipo de parámetro en setCamera no funcionaría aquí, ya que quieres permitir objetos LngLatLike para la propiedad center. Y no puedes escribir "CameraOptions extends Partial<Camera>" ya que LngLatLike es un superconjunto de LngLat, no un subconjunto(Punto 7). Si esto te parece demasiado complicado, también podrías escribir el tipo explícitamente a costa de alguna repetición:

interface CameraOptions {
  center?: LngLatLike;
  zoom?: number;
  bearing?: number;
  pitch?: number;
}

En cualquier caso, con estas nuevas declaraciones de tipo, la función focusOnFeature pasa el comprobador de tipos:

function focusOnFeature(f: Feature) {
  const bounds = calculateBoundingBox(f);
  const camera = viewportForBounds(bounds);
  setCamera(camera);
  const {center: {lat, lng}, zoom} = camera;  // OK
  zoom;  // Type is number
  window.location.search = `?v=@${lat},${lng}z${zoom}`;
}

Esta vez el tipo de zoom es number, en lugar de number|undefined. La función viewportForBounds es ahora mucho más fácil de utilizar. Si hubiera otras funciones que produjeran límites, también tendrías que introducir una forma canónica y una distinción entre LngLatBounds y LngLatBoundsLike.

¿Permitir 19 formas posibles de cuadro delimitador es un buen diseño? Tal vez no. Pero si estás escribiendo declaraciones de tipos para una biblioteca que hace esto, necesitas modelar su comportamiento. Eso sí, ¡no tengas 19 tipos de retorno!

Cosas para recordar

  • Los tipos de entrada suelen ser más amplios que los de salida. Las propiedades opcionales y los tipos de unión son más comunes en los tipos de parámetros que en los tipos de retorno.

  • Para reutilizar tipos entre parámetros y tipos de retorno, introduce una forma canónica (para los tipos de retorno) y una forma más laxa (para los parámetros).

Tema 30: No repitas la información del tipo en la documentación

¿Qué hay de malo en este código en?

/**
 * Returns a string with the foreground color.
 * Takes zero or one arguments. With no arguments, returns the
 * standard foreground color. With one argument, returns the foreground color
 * for a particular page.
 */
function getForegroundColor(page?: string) {
  return page === 'login' ? {r: 127, g: 127, b: 127} : {r: 0, g: 0, b: 0};
}

¡El código y el comentario no están de acuerdo! Sin más contexto es difícil decir cuál es el correcto, pero está claro que algo falla. Como solía decir un profesor mío, "cuando tu código y tus comentarios discrepan, ¡ambos están mal!".

Supongamos que el código representa el comportamiento deseado. Hay algunos problemas con este comentario:

  • Dice que la función devuelve el color como string cuando en realidad devuelve un objeto {r, g, b}.

  • Explica que la función toma cero o uno argumentos, lo que ya queda claro en la firma del tipo.

  • Es innecesariamente prolijo: ¡el comentario es más largo que la declaración de la función y su implementación!

El sistema de anotación de tipos de TypeScript está diseñado para ser compacto, descriptivo y legible. Sus desarrolladores son expertos en el lenguaje con décadas de experiencia. ¡Es casi seguro que es una forma mejor de expresar los tipos de las entradas y salidas de tus funciones que tu prosa!

Y como el compilador de TypeScript comprueba tus anotaciones de tipo, nunca se desincronizarán con la implementación. Quizá getForegroundColor devolvía una cadena, pero luego se cambió para que devolviera un objeto. La persona que hizo el cambio podría haber olvidado actualizar el comentario largo.

Nada permanece sincronizado a menos que se le fuerce a ello. Con las anotaciones de tipo, ¡el verificador de tipos de TypeScript es esa fuerza! Si pones la información de tipos en las anotaciones y no en la documentación, aumentas enormemente tu confianza en que seguirá siendo correcta a medida que evolucione el código.

Un comentario mejor podría ser el siguiente

/** Get the foreground color for the application or a specific page. */
function getForegroundColor(page?: string): Color {
  // ...
}

Si quieres describir un parámetro concreto, utiliza una anotación JSDoc @param. Consulta el Tema 48 para saber más sobre esto.

Los comentarios sobre la falta de mutación también son sospechosos. No te limites a decir que no modificas un parámetro:

/** Does not modify nums */
function sort(nums: number[]) { /* ... */ }

En su lugar, decláralo readonly (punto 17) y deja que TypeScript haga cumplir el contrato:

function sort(nums: readonly number[]) { /* ... */ }

Lo que es válido para los comentarios en también lo es para los nombres de las variables. Evita poner tipos en ellos: en lugar de nombrar una variable ageNum, nómbrala age y asegúrate de que es realmente una number.

Una excepción son los números con unidades. Si no está claro cuáles son las unidades, puedes incluirlas en el nombre de una variable o propiedad. Por ejemplo, timeMs es un nombre mucho más claro que simplemente time, y temperatureC es un nombre mucho más claro que temperature. El punto 37 describe las "marcas", que proporcionan un enfoque más seguro para modelar unidades.

Cosas para recordar

  • Evita repetir información sobre tipos en comentarios y nombres de variables. En el mejor de los casos duplica las declaraciones de tipo, y en el peor dará lugar a información contradictoria.

  • Considera la posibilidad de incluir unidades en los nombres de las variables si no quedan claras en el tipo (por ejemplo, timeMs o temperatureC).

Tema 31: Empuja los valores nulos al perímetro de tus tipos

Cuando activas por primera vez strictNullChecks, puede parecer que tienes que añadir decenas de sentencias if comprobando los valores null y undefined por todo tu código. Esto suele deberse a que las relaciones entre valores nulos y no nulos son implícitas: cuando la variable A es no nula, sabes que la variable B también lo es y viceversa. Estas relaciones implícitas son confusas tanto para los lectores humanos de tu código como para el comprobador de tipos.

Es más fácil trabajar con los valores cuando son completamente nulos o completamente no nulos, en lugar de una mezcla. Puedes modelar esto empujando los valores nulos hacia el perímetro de tus estructuras.

Supongamos que quieres calcular el mínimo y el máximo de una lista de números. Llamaremos a esto "extensión". Aquí tienes un intento:

function extent(nums: number[]) {
  let min, max;
  for (const num of nums) {
    if (!min) {
      min = num;
      max = num;
    } else {
      min = Math.min(min, num);
      max = Math.max(max, num);
    }
  }
  return [min, max];
}

El código comprueba el tipo (sin strictNullChecks) y tiene un tipo de retorno inferido de number[], lo que parece correcto. Pero tiene un error y un fallo de diseño:

  • Si el mín. o máx. es cero, puede que se anule. Por ejemplo, extent([0, 1, 2]) devolverá [1, 2] en lugar de [0, 2].

  • Si la matriz nums está vacía, la función devolverá [undefined, undefined]. Este tipo de objeto con varios undefineds será difícil de trabajar para los clientes y es exactamente la clase de tipo que este artículo desaconseja. Sabemos por la lectura del código fuente que min y max serán ambos undefined o ninguno de los dos, pero esa información no está representada en el sistema de tipos.

Si activas strictNullChecks, ambos problemas se hacen más evidentes:

function extent(nums: number[]) {
  let min, max;
  for (const num of nums) {
    if (!min) {
      min = num;
      max = num;
    } else {
      min = Math.min(min, num);
      max = Math.max(max, num);
                  // ~~~ Argument of type 'number | undefined' is not
                  //     assignable to parameter of type 'number'
    }
  }
  return [min, max];
}

El tipo de retorno de extent se infiere ahora como (number | undefined)[], lo que hace más evidente el fallo de diseño. Es probable que esto se manifieste como un error de tipo siempre que llames a extent:

const [min, max] = extent([0, 1, 2]);
const span = max - min;
          // ~~~   ~~~ Object is possibly 'undefined'

El error en la implementación de extent se produce porque has excluido undefined como valor para min pero no para max. Los dos se inicializan juntos, pero esta información no está presente en el sistema de tipos. Podrías hacerlo desaparecer añadiendo también una comprobación para max, pero esto sería duplicar el error.

Una solución mejor es poner el mínimo y el máximo en el mismo objeto y hacer que este objeto sea totalmente null o totalmente nonull:

function extent(nums: number[]) {
  let result: [number, number] | null = null;
  for (const num of nums) {
    if (!result) {
      result = [num, num];
    } else {
      result = [Math.min(num, result[0]), Math.max(num, result[1])];
    }
  }
  return result;
}

El tipo de retorno es ahora [number, number] | null, con el que es más fácil trabajar para los clientes. Los valores mínimo y máximo pueden recuperarse con una aserción no nula:

const [min, max] = extent([0, 1, 2])!;
const span = max - min;  // OK

o un solo cheque:

const range = extent([0, 1, 2]);
if (range) {
  const [min, max] = range;
  const span = max - min;  // OK
}

Al utilizar un único objeto para rastrear la extensión, hemos mejorado nuestro diseño, hemos ayudado a TypeScript a comprender la relación entre valores nulos y hemos corregido el error: la comprobación de if (!result) ya no da problemas.

Una mezcla de valores nulos y no nulos también puede provocar problemas en las clases. Por ejemplo, supongamos que tienes una clase que representa tanto a un usuario como a sus mensajes en un foro:

class UserPosts {
  user: UserInfo | null;
  posts: Post[] | null;

  constructor() {
    this.user = null;
    this.posts = null;
  }

  async init(userId: string) {
    return Promise.all([
      async () => this.user = await fetchUser(userId),
      async () => this.posts = await fetchPostsForUser(userId)
    ]);
  }

  getUserName() {
    // ...?
  }
}

Mientras se cargan las dos solicitudes de red, las propiedades user y posts serán null. En cualquier momento, ambas pueden ser null, una puede ser null, o ambas pueden no sernull. Existen cuatro posibilidades. Esta complejidad se filtrará en todos los métodos de la clase. Es casi seguro que este diseño provocará confusión, una proliferación de comprobaciones null y errores.

Un diseño mejor esperaría hasta que todos los datos utilizados por la clase estuvieran disponibles:

class UserPosts {
  user: UserInfo;
  posts: Post[];

  constructor(user: UserInfo, posts: Post[]) {
    this.user = user;
    this.posts = posts;
  }

  static async init(userId: string): Promise<UserPosts> {
    const [user, posts] = await Promise.all([
      fetchUser(userId),
      fetchPostsForUser(userId)
    ]);
    return new UserPosts(user, posts);
  }

  getUserName() {
    return this.user.name;
  }
}

Ahora la clase UserPosts es totalmente nonull, y es fácil escribir métodos correctos en ella. Por supuesto, si necesitas realizar operaciones mientras los datos están parcialmente cargados, entonces tendrás que lidiar con la multiplicidad de estados null y nonull.

(No caigas en la tentación de sustituir las propiedades anulables por Promesas. Esto suele conducir a un código aún más confuso y obliga a que todos tus métodos sean asíncronos. Las promesas aclaran el código que carga datos, pero tienden a tener el efecto contrario en la clase que utiliza esos datos).

Cosas para recordar

  • Evita diseños en los que un valor que sea null o no null esté implícitamente relacionado con otro valor que sea null o no null.

  • Empuja los valores null hacia el perímetro de tu API haciendo que los objetos más grandes sean null o totalmente nonull. Esto hará que el código sea más claro tanto para los lectores humanos como para el comprobador de tipos.

  • Considera la posibilidad de crear una clase totalmente nonull y construirla cuando todos los valores estén disponibles.

  • Aunque strictNullChecks puede señalar muchos problemas en tu código, es indispensable para sacar a la luz el comportamiento de las funciones con respecto a los valores nulos.

Tema 32: Prefiere Uniones de Interfaces a Interfaces de Uniones

Si creas una interfaz cuyas propiedades son tipos de unión, debes preguntarte si el tipo tendría más sentido como unión de interfaces más precisas.

Supón que estás construyendo un programa de dibujo vectorial y quieres definir una interfaz para capas con tipos de geometría específicos:

interface Layer {
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}

El campo layout controla cómo y dónde se dibujan las formas (¿esquinas redondeadas? ¿rectas?), mientras que el campo paint controla los estilos (¿la línea es azul? ¿gruesa? ¿fina? ¿punteada?).

¿Tendría sentido tener una capa cuyo layout sea LineLayout pero cuya propiedad paint sea FillPaint? Probablemente no. Permitir esta posibilidad hace que el uso de la biblioteca sea más propenso a errores y dificulta el trabajo con esta interfaz.

Una forma mejor de modelar esto es con interfaces separadas para cada tipo de capa:

interface FillLayer {
  layout: FillLayout;
  paint: FillPaint;
}
interface LineLayer {
  layout: LineLayout;
  paint: LinePaint;
}
interface PointLayer {
  layout: PointLayout;
  paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;

Al definir Layer de esta forma, has excluido la posibilidad de que se mezclen las propiedades layout y paint. Éste es un ejemplo de cómo seguir el consejo del Tema28 de preferir tipos que sólo representen estados válidos.

El ejemplo más común de este patrón es la "unión etiquetada" (o "unión discriminada"). En este caso, una de las propiedades es una unión de tipos literales de cadena:

interface Layer {
  type: 'fill' | 'line' | 'point';
  layout: FillLayout | LineLayout | PointLayout;
  paint: FillPaint | LinePaint | PointPaint;
}

Como antes, ¿tendría sentido tener type: 'fill' pero luego un LineLayout y PointPaint? Desde luego que no. Convierte Layer en una unión de interfaces para excluir esta posibilidad:

interface FillLayer {
  type: 'fill';
  layout: FillLayout;
  paint: FillPaint;
}
interface LineLayer {
  type: 'line';
  layout: LineLayout;
  paint: LinePaint;
}
interface PointLayer {
  type: 'paint';
  layout: PointLayout;
  paint: PointPaint;
}
type Layer = FillLayer | LineLayer | PointLayer;

La propiedad type es la "etiqueta" y puede utilizarse para determinar con qué tipo de Layer estás trabajando en tiempo de ejecución. TypeScript también es capaz de acotar el tipo de Layer basándose en la etiqueta:

function drawLayer(layer: Layer) {
  if (layer.type === 'fill') {
    const {paint} = layer;  // Type is FillPaint
    const {layout} = layer;  // Type is FillLayout
  } else if (layer.type === 'line') {
    const {paint} = layer;  // Type is LinePaint
    const {layout} = layer;  // Type is LineLayout
  } else {
    const {paint} = layer;  // Type is PointPaint
    const {layout} = layer;  // Type is PointLayout
  }
}

Al modelar correctamente la relación entre las propiedades de este tipo, ayudas a TypeScript a comprobar la corrección de tu código. El mismo código con la definición inicial de Layer habría estado abarrotado de aserciones de tipo.

Debido a que funcionan tan bien con el verificador de tipos de TypeScript, las uniones etiquetadas son omnipresentes en el código TypeScript. Reconoce este patrón y aplícalo siempre que puedas. Si puedes representar un tipo de datos en TypeScript con una unión etiquetada, suele ser una buena idea hacerlo. Si piensas en los campos opcionales como una unión de su tipo y undefined, entonces también se ajustan a este patrón. Considera este tipo:

interface Person {
  name: string;
  // These will either both be present or not be present
  placeOfBirth?: string;
  dateOfBirth?: Date;
}

El comentario con información sobre el tipo es una señal clara de que puede haber un problema(punto 30). Existe una relación entre los campos placeOfBirth y dateOfBirth de la que no has informado a TypeScript.

Una forma mejor de modelar esto es trasladar ambas propiedades a un único objeto. Esto es similar a trasladar los valores de null al perímetro(Tema 31):

interface Person {
  name: string;
  birth?: {
    place: string;
    date: Date;
  }
}

Ahora TypeScript se queja de los valores con un lugar pero sin fecha de nacimiento:

const alanT: Person = {
  name: 'Alan Turing',
  birth: {
// ~~~~ Property 'date' is missing in type
//      '{ place: string; }' but required in type
//      '{ place: string; date: Date; }'
    place: 'London'
  }
}

Además, una función que toma un objeto Person sólo necesita hacer una única comprobación:

function eulogize(p: Person) {
  console.log(p.name);
  const {birth} = p;
  if (birth) {
    console.log(`was born on ${birth.date} in ${birth.place}.`);
  }
}

Si la estructura del tipo está fuera de tu control (por ejemplo, procede de una API), aún puedes modelar la relación entre estos campos utilizando una ya familiar unión de interfaces:

interface Name {
  name: string;
}

interface PersonWithBirth extends Name {
  placeOfBirth: string;
  dateOfBirth: Date;
}

type Person = Name | PersonWithBirth;

Ahora obtienes algunas de las mismas ventajas que con el objeto anidado:

function eulogize(p: Person) {
  if ('placeOfBirth' in p) {
    p // Type is PersonWithBirth
    const {dateOfBirth} = p  // OK, type is Date
  }
}

En ambos casos, la definición del tipo deja más clara la relación entre las propiedades.

Cosas para recordar

  • Las interfaces con múltiples propiedades que son tipos de unión suelen ser un error porque oscurecen las relaciones entre estas propiedades.

  • Las uniones de interfaces son más precisas y pueden ser comprendidas por TypeScript.

  • Considera añadir una "etiqueta" a tu estructura para facilitar el análisis del flujo de control de TypeScript. Al estar tan bien soportadas, las uniones etiquetadas son omnipresentes en el código TypeScript.

Tema 33: Prefiere alternativas más precisas a los tipos de cadena

El dominio del tipo string es grande: "x" y "y" están en él, pero también lo está el texto completo de Moby Dick (empieza "Call me Ishmael…" y tiene alrededor de 1,2 millones de caracteres). Cuando declares una variable del tipo string, debes preguntarte si sería más apropiado un tipo más estrecho.

Supón que estás creando una colección de música y quieres definir un tipo para un álbum. Aquí tienes un intento:

interface Album {
  artist: string;
  title: string;
  releaseDate: string;  // YYYY-MM-DD
  recordingType: string;  // E.g., "live" or "studio"
}

La prevalencia de los tipos string y la información sobre tipos en los comentarios (véase el punto 30) son fuertes indicios de que este interface no es del todo correcto. He aquí lo que puede ir mal:

const kindOfBlue: Album = {
  artist: 'Miles Davis',
  title: 'Kind of Blue',
  releaseDate: 'August 17th, 1959',  // Oops!
  recordingType: 'Studio',  // Oops!
};  // OK

El campo releaseDate tiene un formato incorrecto (según el comentario) y "Studio" está en mayúsculas donde debería estar en minúsculas. Pero ambos valores son cadenas, por lo que este objeto es asignable a Album y el verificador de tipos no se queja.

Estos tipos amplios de string también pueden enmascarar errores de objetos válidos de Album. Por ejemplo:

function recordRelease(title: string, date: string) { /* ... */ }
recordRelease(kindOfBlue.releaseDate, kindOfBlue.title);  // OK, should be error

Los parámetros se invierten en la llamada a recordRelease, pero ambos son cadenas, por lo que el comprobador de tipos no se queja. Debido a la prevalencia de los tipos string, el código como éste a veces se denomina "tipado por cadenas".

¿Puedes hacer los tipos más estrechos para evitar este tipo de problemas? Aunque el texto completo de Moby Dick sería un pesado nombre de artista o título de álbum, al menos es plausible. Así que string es apropiado para estos campos. Para el campo releaseDate es mejor utilizar simplemente un objeto Date y evitar problemas de formato. Por último, para el campo recordingType, puedes definir un tipo de unión con sólo dos valores (también podrías utilizar un enum, pero en general recomiendo evitarlos; véase el punto 53):

type RecordingType = 'studio' | 'live';

interface Album {
  artist: string;
  title: string;
  releaseDate: Date;
  recordingType: RecordingType;
}

Con estos cambios, TypeScript puede realizar una comprobación más exhaustiva en busca de errores:

const kindOfBlue: Album = {
  artist: 'Miles Davis',
  title: 'Kind of Blue',
  releaseDate: new Date('1959-08-17'),
  recordingType: 'Studio'
// ~~~~~~~~~~~~ Type '"Studio"' is not assignable to type 'RecordingType'
};

Este enfoque tiene ventajas, además de una comprobación más estricta. En primer lugar, definir explícitamente el tipo garantiza que su significado no se pierda al pasar de un tipo a otro. Si, por ejemplo, quisieras encontrar álbumes sólo de un determinado tipo de grabación, podrías definir una función como ésta:

function getAlbumsOfType(recordingType: string): Album[] {
  // ...
}

¿Cómo sabe la persona que llama a esta función qué se espera que sea recordingType? Es sólo un string. El comentario que explica que es "studio" o "live" está oculto en la definición de Album, donde el usuario podría no pensar en mirar.

En segundo lugar, definir explícitamente un tipo te permite adjuntarle documentación (ver Tema 48):

/** What type of environment was this recording made in?  */
type RecordingType = 'live' | 'studio';

Cuando cambias getAlbumsOfType por RecordingType, la persona que llama puede hacer clic y ver la documentación (ver Figura 4-1).

efts 04in01
Figura 4-1. Utilizar un tipo con nombre en lugar de una cadena permite adjuntar documentación al tipo que aparece en tu editor.

Otro mal uso habitual de string es en los parámetros de las funciones. Supongamos que quieres escribir una función que extraiga todos los valores de un único campo de una matriz. La biblioteca Underscore llama a esto "arrancar":

function pluck(records, key) {
  return records.map(r => r[key]);
}

¿Cómo escribirías esto? Aquí tienes un primer intento:

function pluck(records: any[], key: string): any[] {
  return records.map(r => r[key]);
}

Este tipo se comprueba pero no es genial. Los tipos any son problemáticos, sobre todo en el valor de retorno (véase el punto 38). El primer paso para mejorar la firma de tipos es introducir un parámetro de tipo genérico:

function pluck<T>(records: T[], key: string): any[] {
  return records.map(r => r[key]);
                       // ~~~~~~ Element implicitly has an 'any' type
                       //        because type '{}' has no index signature
}

TypeScript se queja ahora de que el tipo string para key es demasiado amplio. Y tiene razón al hacerlo: si pasas una matriz de Albums, entonces sólo hay cuatro valores válidos para key ("artista", "título", "releaseDate" y "recordingType"), frente al vasto conjunto de cadenas. Esto es precisamente lo que es el tipo keyof Album:

type K = keyof Album;
// Type is "artist" | "title" | "releaseDate" | "recordingType"

Así que la solución es sustituir string por keyof T:

function pluck<T>(records: T[], key: keyof T) {
  return records.map(r => r[key]);
}

Esto pasa el comprobador de tipos. También hemos dejado que TypeScript deduzca el tipo de retorno. ¿Cómo lo hace? Si pasas el ratón por encima de pluck en tu editor, el tipo inferido es:

function pluck<T>(record: T[], key: keyof T): T[keyof T][]

T[keyof T] es el tipo de cualquier valor posible en T. Si pasas una sola cadena como key, esto es demasiado amplio. Por ejemplo:

const releaseDates = pluck(albums, 'releaseDate'); // Type is (string | Date)[]

El tipo debería ser Date[], no (string | Date)[]. Aunque keyof T es mucho más estrecho que string, sigue siendo demasiado amplio. Para estrecharlo aún más, necesitamos introducir un segundo parámetro genérico que sea un subconjunto de keyof T (probablemente un único valor):

function pluck<T, K extends keyof T>(records: T[], key: K): T[K][] {
  return records.map(r => r[key]);
}

(Para más información sobre extends en este contexto, véase el Tema 14.)

Ahora la firma de tipo es completamente correcta. Podemos comprobarlo llamando a pluck de varias formas distintas:

pluck(albums, 'releaseDate'); // Type is Date[]
pluck(albums, 'artist');  // Type is string[]
pluck(albums, 'recordingType');  // Type is RecordingType[]
pluck(albums, 'recordingDate');
           // ~~~~~~~~~~~~~~~ Argument of type '"recordingDate"' is not
           //                 assignable to parameter of type ...

El servicio lingüístico es capaz incluso de ofrecer autocompletar en las teclas de Album (como se muestra en la Figura 4-2).

efts 04in02
Figura 4-2. Si utilizas un tipo de parámetro clave de Álbum en lugar de cadena, conseguirás un mejor autocompletado en tu editor.

string tiene algunos de los mismos problemas que any: cuando se utiliza de forma inadecuada, permite valores no válidos y oculta las relaciones entre tipos. Esto frustra al verificador de tipos y puede ocultar errores reales. La capacidad de TypeScript de definir subconjuntos de string es una forma poderosa de aportar seguridad de tipos al código JavaScript. Utilizar tipos más precisos detectará errores y mejorará la legibilidad de tu código.

Cosas para recordar

  • Evita el código "stringly typed". Prefiere tipos más apropiados en los que no todas las string sean una posibilidad.

  • Prefiere una unión de tipos literales de cadena a string si eso describe con más precisión el dominio de una variable. Conseguirás una comprobación de tipos más estricta y mejorarás la experiencia de desarrollo.

  • Prefiere keyof T a string para los parámetros de función que se espera que sean propiedades de un objeto.

Tema 34: Prefiere los Tipos Incompletos a los Inexactos

En escribiendo declaraciones de tipos encontrarás inevitablemente situaciones en las que puedes modelar el comportamiento de forma más o menos precisa. La precisión en los tipos es generalmente algo bueno porque ayudará a tus usuarios a detectar errores y a aprovechar las herramientas que proporciona TypeScript. Pero ten cuidado al aumentar la precisión de tus declaraciones de tipos: es fácil cometer errores, y los tipos incorrectos pueden ser peor que no tener tipos.

Supongamos que estás escribiendo declaraciones de tipo para GeoJSON, un formato que ya hemos visto en el Tema 31. Una Geometría GeoJSON puede ser de unos cuantos tipos, cada uno de los cuales tiene matrices de coordenadas con formas diferentes:

interface Point {
  type: 'Point';
  coordinates: number[];
}
interface LineString {
  type: 'LineString';
  coordinates: number[][];
}
interface Polygon {
  type: 'Polygon';
  coordinates: number[][][];
}
type Geometry = Point | LineString | Polygon;  // Also several others

Esto está bien, pero number[] para una coordenada es un poco impreciso. En realidad se trata de latitudes y longitudes, por lo que quizá sería mejor un tipo tupla:

type GeoPosition = [number, number];
interface Point {
  type: 'Point';
  coordinates: GeoPosition;
}
// Etc.

Publicas tus tipos más precisos al mundo y esperas a que llegue la adulación. Por desgracia, un usuario se queja de que tus nuevos tipos lo han roto todo. Aunque sólo has utilizado la latitud y la longitud, una posición en GeoJSON puede tener un tercer elemento, una elevación y potencialmente más. En un intento de hacer más precisas las declaraciones de tipos, ¡has ido demasiado lejos y has hecho que los tipos sean inexactos! Para seguir utilizando tus declaraciones de tipos, tu usuario tendrá que introducir aserciones de tipos o silenciar por completo el verificador de tipos con as any.

Como otro ejemplo, considera la posibilidad de intentar escribir declaraciones de tipos para un lenguaje tipo Lisp definido en JSON:

12
"red"
["+", 1, 2]  // 3
["/", 20, 2]  // 10
["case", [">", 20, 10], "red", "blue"]  // "red"
["rgb", 255, 0, 127]  // "#FF007F"

La biblioteca Mapbox de utiliza un sistema como éste para determinar la apariencia de las características de los mapas en muchos dispositivos. Hay todo un espectro de precisión con el que podrías intentar escribir esto:

  1. Permite cualquier cosa.

  2. Permite cadenas, números y matrices.

  3. Permite cadenas, números y matrices que empiecen por nombres de función conocidos.

  4. Asegúrate de que cada función recibe el número correcto de argumentos.

  5. Asegúrate de que cada función recibe el tipo correcto de argumentos.

Las dos primeras opciones son sencillas:

type Expression1 = any;
type Expression2 = number | string | any[];

Más allá de esto, deberías introducir un conjunto de pruebas de expresiones que sean válidas y expresiones que no lo sean. A medida que vayas precisando tus tipos, esto te ayudará a evitar regresiones (véase el punto 52):

const tests: Expression2[] = [
  10,
  "red",
  true,
// ~~~ Type 'true' is not assignable to type 'Expression2'
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],  // Too many values
  ["**", 2, 31],  // Should be an error: no "**" function
  ["rgb", 255, 128, 64],
  ["rgb", 255, 0, 127, 0]  // Too many values
];

Para pasar al siguiente nivel de precisión, puedes utilizar una unión de tipos literales de cadena como primer elemento de una tupla:

type FnName = '+' | '-' | '*' | '/' | '>' | '<' | 'case' | 'rgb';
type CallExpression = [FnName, ...any[]];
type Expression3 = number | string | CallExpression;

const tests: Expression3[] = [
  10,
  "red",
  true,
// ~~~ Type 'true' is not assignable to type 'Expression3'
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],
  ["**", 2, 31],
// ~~~~~~~~~~~ Type '"**"' is not assignable to type 'FnName'
  ["rgb", 255, 128, 64],
  ["rgb", 255, 0, 127, 0]  // Too many values
];

Hay un nuevo error detectado y ninguna regresión. ¡Bastante bien!

¿Qué pasa en si quieres asegurarte de que cada función recibe el número correcto de argumentos? Esto se complica, ya que ahora el tipo debe ser recursivo para llegar a todas las llamadas a funciones. TypeScript lo permite, aunque tenemos que tener cuidado de convencer al verificador de tipos de que nuestra recursión no es infinita. En este caso, eso significa definir CaseCall (que debe ser una matriz de longitud par) con un interface en lugar de un type. Esto es posible, aunque un poco incómodo:

type Expression4 = number | string | CallExpression;

type CallExpression = MathCall | CaseCall | RGBCall;

type MathCall = [
  '+' | '-' | '/' | '*' | '>' | '<',
  Expression4,
  Expression4,
];

interface CaseCall {
  0: 'case';
  1: Expression4;
  2: Expression4;
  3: Expression4;
  4?: Expression4;
  5?: Expression4;
  // etc.
  length: 4 | 6 | 8 | 10 | 12 | 14 | 16; // etc.
}

type RGBCall = ['rgb', Expression4, Expression4, Expression4];

const tests: Expression4[] = [
  10,
  "red",
  true,
// ~~~ Type 'true' is not assignable to type 'Expression4'
  ["+", 10, 5],
  ["case", [">", 20, 10], "red", "blue", "green"],
// ~~~~~~                                ~~~~~~~
// Type '"case"' is not assignable to type '"rgb"'.
//          Type 'string' is not assignable to type 'undefined'.
  ["**", 2, 31],
// ~~~~ Type '"**"' is not assignable to type '"+" | "-" | "/" | ...
  ["rgb", 255, 128, 64],
  ["rgb", 255, 0, 127, 0]
  //                   ~ Type 'number' is not assignable to type 'undefined'.
];

Ahora todas las expresiones no válidas producen errores. Y es interesante que puedas expresar algo como "un array de longitud par" utilizando un TypeScript interface. Pero algunos de estos mensajes de error no son muy buenos, sobre todo el que dice que "case" no se puede asignar a "rgb".

¿Es una mejora respecto a los tipos anteriores, menos precisos? El hecho de que obtengas errores para algunos usos incorrectos es una victoria, pero los mensajes de error confusos harán que sea más difícil trabajar con este tipo. Los servicios lingüísticos forman parte de la experiencia TypeScript tanto como la comprobación de tipos (véase el punto 6), por lo que es una buena idea observar los mensajes de error resultantes de tus declaraciones de tipos y probar el autocompletado en situaciones en las que debería funcionar. Si tus nuevas declaraciones de tipos son más precisas pero rompen el autocompletado, entonces harán que la experiencia de desarrollo de TypeScript sea menos agradable.

La complejidad de esta declaración de tipos también ha aumentado las probabilidades de que se cuele un error. Por ejemplo, Expression4 requiere que todos los operadores matemáticos tomen dos parámetros, pero la especificación de la expresión Mapbox dice que + y * pueden tomar más. Además, - puede tomar un solo parámetro, en cuyo caso niega su entrada. Expression4 indica incorrectamente errores en todos ellos:

const okExpressions: Expression4[] = [
  ['-', 12],
// ~~~ Type '"-"' is not assignable to type '"rgb"'.
  ['+', 1, 2, 3],
// ~~~ Type '"+"' is not assignable to type '"rgb"'.
  ['*', 2, 3, 4],
// ~~~ Type '"*"' is not assignable to type '"rgb"'.
];

Una vez más, al intentar ser más precisos nos hemos pasado y nos hemos vuelto inexactos. Estas imprecisiones pueden corregirse, pero querrás ampliar tu conjunto de pruebas para convencerte de que no se te ha escapado nada más. El código complejo suele requerir más pruebas, y lo mismo ocurre con los tipos.

Al refinar tipos, puede ser útil pensar en la metáfora del "valle misterioso". Refinar tipos muy imprecisos como any suele ser útil. Pero a medida que tus tipos se hacen más precisos, aumenta la expectativa de que también sean exactos. Empezarás a confiar más en los tipos, por lo que las imprecisiones producirán mayores problemas.

Cosas para recordar

  • Evita el valle misterioso de la seguridad de tipos: los tipos incorrectos suelen ser peores que la ausencia de tipos.

  • Si no puedes modelar un tipo con precisión, ¡no lo modeles con inexactitud! Reconoce las lagunas utilizando any o unknown.

  • Presta atención a los mensajes de error y a la función de autocompletar a medida que haces que la tipificación sea cada vez más precisa. No se trata sólo de la corrección: la experiencia del desarrollador también importa.

Tema 35: Generar tipos a partir de API y especificaciones, no de datos

Los otros artículos de en este capítulo han discutido los muchos beneficios de diseñar bien tus tipos y han mostrado lo que puede ir mal si no lo haces. Un tipo bien diseñado hace que TypeScript sea un placer de usar, mientras que uno mal diseñado puede hacerlo miserable. Pero esto ejerce bastante presión sobre el diseño de tipos. ¿No estaría bien que no tuvieras que hacerlo tú mismo?

Es probable que al menos algunos de tus tipos provengan de fuera de tu programa: formatos de archivo, API o especificaciones. En estos casos, puedes evitar escribir tipos generándolos en su lugar. Si lo haces, la clave está en generar tipos a partir de especificaciones, en lugar de a partir de datos de ejemplo. Cuando generas tipos a partir de una especificación, TypeScript te ayudará a asegurarte de que no te has saltado ningún caso. Cuando generas tipos a partir de datos, sólo tienes en cuenta los ejemplos que has visto. Podrías estar pasando por alto casos de perímetro importantes que podrían romper tu programa.

En el Tema 31 escribimos una función para calcular el cuadro delimitador de una Característica GeoJSON de. Esto es lo que parecía:

function calculateBoundingBox(f: GeoJSONFeature): BoundingBox | null {
  let box: BoundingBox | null = null;

  const helper = (coords: any[]) => {
    // ...
  };

  const {geometry} = f;
  if (geometry) {
    helper(geometry.coordinates);
  }

  return box;
}

El tipo GeoJSONFeature nunca se definió explícitamente. Podrías escribirlo utilizando algunos de los ejemplos del Tema 31. Pero un enfoque mejor es utilizar la especificación formal GeoJSON.1 Afortunadamente para nosotros, ya existen declaraciones de tipo TypeScript para él en DefinitelyTyped. Puedes añadirlas de la forma habitual:

$ npm install --save-dev @types/geojson
+ @types/geojson@7946.0.7

Cuando introduces las declaraciones GeoJSON, TypeScript señala inmediatamente un error:

import {Feature} from 'geojson';

function calculateBoundingBox(f: Feature): BoundingBox | null {
  let box: BoundingBox | null = null;

  const helper = (coords: any[]) => {
    // ...
  };

  const {geometry} = f;
  if (geometry) {
    helper(geometry.coordinates);
                 // ~~~~~~~~~~~
                 // Property 'coordinates' does not exist on type 'Geometry'
                 //   Property 'coordinates' does not exist on type
                 //   'GeometryCollection'
  }

  return box;
}

El problema es que tu código supone que una geometría tendrá una propiedad coordinates. Esto es cierto para muchas geometrías, incluidos puntos, líneas y polígonos. Pero una geometría GeoJSON también puede ser una GeometryCollection, una colección heterogénea de otras geometrías. A diferencia de los otros tipos de geometría, no tiene una propiedad coordinates.

Si llamas a calculateBoundingBox en una Característica cuya geometría es GeometryCollection, arrojará un error sobre la imposibilidad de leer la propiedad 0 de undefined. ¡Esto es un verdadero error! Y lo hemos detectado utilizando definiciones de tipos de una especificación.

Una opción para solucionarlo es desautorizar explícitamente GeometryCollections, como se muestra aquí:

const {geometry} = f;
if (geometry) {
  if (geometry.type === 'GeometryCollection') {
    throw new Error('GeometryCollections are not supported.');
  }
  helper(geometry.coordinates);  // OK
}

TypeScript es capaz de refinar el tipo de geometry basándose en la comprobación, por lo que la referencia a geometry.coordinates está permitida. Aunque sólo sea por eso, el mensaje de error es más claro para el usuario.

Pero ¡la mejor solución es admitir todos los tipos de geometría! Puedes hacerlo sacando otra función auxiliar:

const geometryHelper = (g: Geometry) => {
  if (geometry.type === 'GeometryCollection') {
    geometry.geometries.forEach(geometryHelper);
  } else {
    helper(geometry.coordinates);  // OK
  }
}

const {geometry} = f;
if (geometry) {
  geometryHelper(geometry);
}

Si hubieras escrito tú mismo las declaraciones de tipos para GeoJSON, las habrías basado en tu comprensión y experiencia con el formato. Esto podría no haber incluido GeometryCollections y te habría llevado a una falsa sensación de seguridad sobre la corrección de tu código. Utilizar tipos basados en una especificación te da la seguridad de que tu código funcionará con todos los valores, no sólo con los que has visto.

Consideraciones similares se aplican a las llamadas a la API: si puedes generar tipos a partir de la especificación de una API, suele ser una buena idea hacerlo. Esto funciona especialmente bien con las API que se tipifican a sí mismas, como GraphQL.

Una API GraphQL viene con un esquema que especifica todas las consultas e interfaces posibles utilizando un sistema de tipos algo similar a TypeScript. Escribes consultas que solicitan campos específicos en estas interfaces. Por ejemplo, para obtener información sobre un repositorio utilizando la API GraphQL de GitHub podrías escribir:

query {
  repository(owner: "Microsoft", name: "TypeScript") {
    createdAt
    description
  }
}

El resultado es:

{
  "data": {
    "repository": {
      "createdAt": "2014-06-17T15:28:39Z",
      "description":
        "TypeScript is a superset of JavaScript that compiles to JavaScript."
    }
  }
}

Lo bueno de este enfoque es que puedes generar tipos TypeScript para tu consulta específica. Al igual que con el ejemplo de GeoJSON, esto ayuda a garantizar que modelas con precisión las relaciones entre tipos y su anulabilidad.

Aquí tienes una consulta para obtener la licencia de código abierto de un repositorio de GitHub:

query getLicense($owner:String!, $name:String!){
  repository(owner:$owner, name:$name) {
    description
    licenseInfo {
      spdxId
      name
    }
  }
}

$owner y $name son variables GraphQL que a su vez están tipadas. La sintaxis de tipos es lo suficientemente similar a la de TypeScript como para que pueda resultar confuso ir de un lado a otro. String es un tipo GraphQL -sería string en TypeScript (véase el punto 10). Y mientras que los tipos TypeScript no son anulables, los tipos en GraphQL sí lo son. El ! después del tipo indica que está garantizado que no es nulo.

Hay muchas herramientas para ayudarte a pasar de una consulta GraphQL a tipos TypeScript. Una de ellas es Apollo. Te explicamos cómo utilizarla:

$ apollo client:codegen \
    --endpoint https://api.github.com/graphql \
    --includes license.graphql \
    --target typescript
Loading Apollo Project
Generating query files with 'typescript' target - wrote 2 files

Necesitas un esquema GraphQL para generar tipos para una consulta. Apollo lo obtiene del punto final api.github.com/graphql. La salida tiene este aspecto:

export interface getLicense_repository_licenseInfo {
  __typename: "License";
  /** Short identifier specified by <https://spdx.org/licenses> */
  spdxId: string | null;
  /** The license full name specified by <https://spdx.org/licenses> */
  name: string;
}

export interface getLicense_repository {
  __typename: "Repository";
  /** The description of the repository. */
  description: string | null;
  /** The license associated with the repository */
  licenseInfo: getLicense_repository_licenseInfo | null;
}

export interface getLicense {
  /** Lookup a given repository by the owner and repository name. */
  repository: getLicense_repository | null;
}

export interface getLicenseVariables {
  owner: string;
  name: string;
}

Las partes importantes a tener en cuenta aquí son

  • Se generan interfaces tanto para los parámetros de consulta (getLicenseVariables) como para la respuesta (getLicense).

  • La información de anulabilidad se transfiere del esquema a las interfaces de respuesta. Los campos repository, description, licenseInfo, y spdxId son anulables, mientras que la licencia name y las variables de consulta no lo son.

  • La documentación se transfiere como JSDoc para que aparezca en tu editor(Tema 48). Estos comentarios proceden del propio esquema GraphQL.

Esta información sobre los tipos ayuda a garantizar que utilizas la API correctamente. Si cambian tus consultas, cambiarán los tipos. Si cambia el esquema, también cambiarán tus tipos. No hay riesgo de que tus tipos y la realidad diverjan, ya que ambos proceden de una única fuente de verdad: el esquema GraphQL.

¿Y si no hay ninguna especificación o esquema oficial disponible? Entonces tendrás que generar tipos a partir de los datos. Herramientas como quicktype pueden ayudarte con esto. Pero ten en cuenta que tus tipos pueden no coincidir con la realidad: puede haber casos de perímetro que hayas pasado por alto.

Aunque no seas consciente de ello, ya te estás beneficiando de la generación de código. Las declaraciones de tipos de TypeScript para la API DOM del navegador se generan a partir de las interfaces oficiales (ver punto 55). Esto garantiza que modelan correctamente un sistema complicado y ayuda a TypeScript a detectar errores y malentendidos en tu propio código.

Cosas para recordar

  • Considera la posibilidad de generar tipos en para las llamadas a la API y los formatos de datos, a fin de llevar la seguridad de tipos hasta el perímetro de tu código.

  • Prefiere generar código a partir de especificaciones en lugar de datos. ¡Los casos raros importan!

Tema 36: Nombra los Tipos Utilizando el Lenguaje de tu Dominio Problemático

Sólo hay dos problemas difíciles en Informática: invalidar la caché y nombrar las cosas.

Phil Karlton

Este libro ha tenido mucho que decir sobre la forma de los tipos y los conjuntos de valores en sus dominios, pero mucho menos sobre cómo nombrar tus tipos. Pero esto también es una parte importante del diseño de tipos. Los nombres de tipos, propiedades y variables bien elegidos pueden aclarar la intención y elevar el nivel de abstracción de tu código y tus tipos. Los tipos mal elegidos pueden oscurecer tu código y conducir a modelos mentales incorrectos.

Supón que estás construyendo una base de datos de animales. Creas una interfaz para representar a uno:

interface Animal {
  name: string;
  endangered: boolean;
  habitat: string;
}

const leopard: Animal = {
  name: 'Snow Leopard',
  endangered: false,
  habitat: 'tundra',
};

Aquí hay algunos problemas:

  • name es un término muy general. ¿Qué tipo de nombre esperas? ¿Un nombre científico? ¿Un nombre común?

  • El campo booleano endangered también es ambiguo. ¿Qué ocurre si un animal está extinguido? ¿La intención aquí es "en peligro o peor"? ¿O significa literalmente en peligro de extinción?

  • El campo habitat es muy ambiguo, no sólo por el tipo demasiado amplio string (Tema 33), sino también porque no está claro qué se entiende por "hábitat".

  • El nombre de la variable es leopard, pero el valor de la propiedad name es "Snow Leopard". ¿Tiene sentido esta distinción?

Aquí tienes una declaración de tipo y valor con menos ambigüedad:

interface Animal {
  commonName: string;
  genus: string;
  species: string;
  status: ConservationStatus;
  climates: KoppenClimate[];
}
type ConservationStatus = 'EX' | 'EW' | 'CR' | 'EN' | 'VU' | 'NT' | 'LC';
type KoppenClimate = |
  'Af' | 'Am' | 'As' | 'Aw' |
  'BSh' | 'BSk' | 'BWh' | 'BWk' |
  'Cfa' | 'Cfb' | 'Cfc' | 'Csa' | 'Csb' | 'Csc' | 'Cwa' | 'Cwb' | 'Cwc' |
  'Dfa' | 'Dfb' | 'Dfc' | 'Dfd' |
  'Dsa' | 'Dsb' | 'Dsc' | 'Dwa' | 'Dwb' | 'Dwc' | 'Dwd' |
  'EF' | 'ET';
const snowLeopard: Animal = {
  commonName: 'Snow Leopard',
  genus: 'Panthera',
  species: 'Uncia',
  status: 'VU',  // vulnerable
  climates: ['ET', 'EF', 'Dfd'],  // alpine or subalpine
};

Esto supone una serie de mejoras:

  • name ha sido sustituido por términos más específicos: commonName, genus, yspecies.

  • endangered se ha convertido en status, un tipo de ConservationStatus que utiliza un sistema de clasificación estándar de la UICN.

  • habitat se ha convertido en climates y utiliza otra taxonomía estándar, la clasificación climática de Köppen.

Si necesitaras más información sobre los campos de la primera versión de este tipo, tendrías que ir a buscar a la persona que los escribió y preguntarle. Lo más probable es que haya dejado la empresa o no se acuerde. Peor aún, podrías consultar git blame para averiguar quién escribió estos pésimos tipos, ¡y descubrir que fuiste tú!

La situación ha mejorado mucho con la segunda versión. Si quieres saber más sobre el sistema de clasificación climática de Köppen o averiguar cuál es el significado exacto de un estado de conservación, existen innumerables recursos en Internet que te ayudarán.

Cada dominio tiene un vocabulario especializado para describir su tema. En lugar de inventar tus propios términos, intenta reutilizar términos del dominio de tu problema. A menudo, estos vocabularios se han perfeccionado a lo largo de años, décadas o siglos, y son bien comprendidos por la gente del sector. Utilizar estos términos te ayudará a comunicarte con los usuarios y aumentará la claridad de tus tipos.

Ten cuidado de utilizar el vocabulario del dominio con precisión: cooptar el lenguaje de un dominio para que signifique algo diferente es aún más confuso que inventar el tuyo propio.

Aquí tienes otras reglas que debes tener en cuenta cuando nombres tipos, propiedades yvariables:

  • Haz que las distinciones tengan sentido. Al escribir y hablar puede resultar tedioso utilizar la misma palabra una y otra vez. Introducimos sinónimos para romper la monotonía. Esto hace que la prosa sea más agradable de leer, pero tiene el efecto contrario en el código. Si utilizas dos términos diferentes, asegúrate de que haces una distinción significativa. Si no es así, debes utilizar el mismo término.

  • Evita nombres vagos y sin sentido como "datos", "información", "cosa", "elemento", "objeto" o la siempre popular "entidad". Si Entidad tiene un significado específico en tu dominio, bien. Pero si lo utilizas porque no se te ocurre un nombre más significativo, al final tendrás problemas.

  • Nombra las cosas por lo que son, no por lo que contienen o por cómo se calculan. Directory es más significativo que INodeList. Te permite pensar en un directorio como un concepto, en lugar de en términos de su implementación. Unos buenos nombres pueden aumentar tu nivel de abstracción y disminuir el riesgo decolisiones involuntarias.

Cosas para recordar

  • Reutiliza nombres del dominio de tu problema siempre que sea posible para aumentar la legibilidad y el nivel de abstracción de tu código.

  • Evita utilizar diferentes nombres para la misma cosa: haz que las distinciones en los nombres tengan sentido.

Tema 37: Considerar las "Marcas" para la Tipificación Nominal

En el punto 4 se habló de tipificación estructural ("pato") y de cómo a veces puede conducir a resultados sorprendentes:

interface Vector2D {
  x: number;
  y: number;
}
function calculateNorm(p: Vector2D) {
  return Math.sqrt(p.x * p.x + p.y * p.y);
}

calculateNorm({x: 3, y: 4});  // OK, result is 5
const vec3D = {x: 3, y: 4, z: 1};
calculateNorm(vec3D);  // OK! result is also 5

¿Y si quisieras que calculateNorm rechazara los vectores 3D? Esto va en contra del modelo de tipado estructural de TypeScript, pero sin duda es más correcto desde el punto de vista matemático.

Una forma de conseguirlo es con la tipificación nominal. Con la tipificación nominal, un valor es un Vector2D porque tú dices que lo es, no porque tenga la forma correcta. Para aproximarte a esto en TypeScript, puedes introducir una "marca" (piensa en vacas, no en Coca-Cola):

interface Vector2D {
  _brand: '2d';
  x: number;
  y: number;
}
function vec2D(x: number, y: number): Vector2D {
  return {x, y, _brand: '2d'};
}
function calculateNorm(p: Vector2D) {
  return Math.sqrt(p.x * p.x + p.y * p.y);  // Same as before
}

calculateNorm(vec2D(3, 4)); // OK, returns 5
const vec3D = {x: 3, y: 4, z: 1};
calculateNorm(vec3D);
           // ~~~~~ Property '_brand' is missing in type...

La marca garantiza que el vector procede del lugar correcto. Por supuesto, nada te impide añadir _brand: '2d' al valor vec3D. Pero esto es pasar de lo accidental a lo malicioso. Este tipo de marca suele bastar para detectar usos inadvertidos de funciones.

Curiosamente, puedes obtener muchas de las mismas ventajas que las marcas explícitas operando sólo en el sistema de tipos. Esto elimina la sobrecarga en tiempo de ejecución y también te permite marcar tipos incorporados como string o number, a los que no puedes añadir propiedades adicionales.

Por ejemplo, ¿qué ocurre si tienes una función que opera en el sistema de archivos y requiere una ruta absoluta (en lugar de relativa)? Esto es fácil de comprobar en tiempo de ejecución (¿la ruta empieza por "/"?), pero no tanto en el sistema de tipos.

He aquí un enfoque con marcas:

type AbsolutePath = string & {_brand: 'abs'};
function listAbsolutePath(path: AbsolutePath) {
  // ...
}
function isAbsolutePath(path: string): path is AbsolutePath {
  return path.startsWith('/');
}

No puedes construir un objeto que sea un string y tenga una propiedad _brand. Esto es puramente un juego con el sistema de tipos.

Si tienes una ruta string que puede ser absoluta o relativa, puedes comprobarla utilizando la guarda de tipo, que refinará su tipo:

function f(path: string) {
  if (isAbsolutePath(path)) {
    listAbsolutePath(path);
  }
  listAbsolutePath(path);
                // ~~~~ Argument of type 'string' is not assignable
                //      to parameter of type 'AbsolutePath'
}

Este tipo de enfoque podría ser útil para documentar qué funciones esperan rutas absolutas o relativas y qué tipo de ruta contiene cada variable. Sin embargo, no es una garantía férrea: path as AbsolutePath tendrá éxito para cualquier string. Pero si evitas este tipo de afirmaciones, la única forma de obtener un AbsolutePath es que te lo den o comprobarlo, que es exactamente lo que quieres.

Este enfoque puede utilizarse para modelar muchas propiedades que no pueden expresarse dentro del sistema de tipos. Por ejemplo, utilizar la búsqueda binaria para encontrar un elemento en una lista:

function binarySearch<T>(xs: T[], x: T): boolean {
  let low = 0, high = xs.length - 1;
  while (high >= low) {
    const mid = low + Math.floor((high - low) / 2);
    const v = xs[mid];
    if (v === x) return true;
    [low, high] = x > v ? [mid + 1, high] : [low, mid - 1];
  }
  return false;
}

Esto funciona si la lista está ordenada, pero dará falsos negativos si no lo está. No puedes representar una lista ordenada en el sistema de tipos de TypeScript. Pero puedes crear una marca:

type SortedList<T> = T[] & {_brand: 'sorted'};

function isSorted<T>(xs: T[]): xs is SortedList<T> {
  for (let i = 1; i < xs.length; i++) {
    if (xs[i] < xs[i - 1]) {
      return false;
    }
  }
  return true;
}

function binarySearch<T>(xs: SortedList<T>, x: T): boolean {
  // ...
}

Para llamar a esta versión de binarySearch, necesitas que te den un SortedList (es decir, tener una prueba de que la lista está ordenada) o demostrar tú mismo que está ordenada utilizando isSorted. La exploración lineal no es muy buena, ¡pero al menos estarás a salvo!

Ésta es una perspectiva útil sobre el verificador de tipos en general. Para llamar a un método de un objeto, por ejemplo, necesitas que te den un objeto nonull o demostrar tú mismo que es nonull con una condicional.

También puedes marcar number tipos-por ejemplo, para unir unidades:

type Meters = number & {_brand: 'meters'};
type Seconds = number & {_brand: 'seconds'};

const meters = (m: number) => m as Meters;
const seconds = (s: number) => s as Seconds;

const oneKm = meters(1000);  // Type is Meters
const oneMin = seconds(60);  // Type is Seconds

Esto puede resultar incómodo en la práctica, ya que las operaciones aritméticas hacen que los números olviden sus marcas:

const tenKm = oneKm * 10;  // Type is number
const v = oneKm / oneMin;  // Type is number

Sin embargo, si tu código implica muchos números con unidades mixtas, éste puede seguir siendo un enfoque atractivo para documentar los tipos esperados de parámetros numéricos.

Cosas para recordar

  • TypeScript utiliza tipado estructural ("pato"), que a veces puede dar lugar a resultados sorprendentes. Si necesitas tipado nominal, considera la posibilidad de adjuntar "marcas" a tus valores para distinguirlos.

  • En algunos casos puedes adjuntar marcas enteramente en el sistema de tipos, en lugar de en tiempo de ejecución. Puedes utilizar esta técnica para modelar propiedades fuera del sistema de tipos de TypeScript.

1 GeoJSON también se conoce como RFC 7946. La especificación, muy legible, está en http://geojson.org.

Get TypeScript eficaz now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.