Aserción, inferencia y anotación literal de tipos en TypeScript

Cuando trabajamos con TypeScript nos apoyamos en la herramienta para que nos ayude a crear nuestro código. Si todo va bien, TypeScript infiere los tipos del propio uso y nos arroja información sobre los errores que se produzcan. Pero en ocasiones, esto no es tan sencillo y debemos ayudar a TypeScript para que comprenda nuestro código.

Hoy te explicaré la diferencia principal entre la aserción de tipos, la inferencia de tipos y la anotación literal de tipos en TypeScript, a través de un ejemplo real con el framework Vue.

TL,DR

Vamos a trabajar con una función que nos devolverá un objeto que contendrá las propiedades de un producto.

Lo primero que haremos será definir una interface que será quien nos describa nuestro modelo de objeto:

type Price = string;
type ProductId = number;

export interface Product {
  id: ProductId;
  author: string;
  title: string;
  price: Price;
}

Ahora vamos a definir nuestra función getProduct que como hemos dicho, sólo deberá crearnos un producto.

Inferencia de tipos

Si asignamos la devolución de esta función a un objeto, TypeScript nos inferirá las propiedades que tiene. Si te fijas, no estamos haciendo uso de ningún tipado por lo que el modelo lo está infiriendo.

const getProduct = () => ({
  id: 1,
  author: 'irrelevant author',
  title: 'irrelevant title',
  price: '45.00'
})

image.png

¿Pero qué pasa con la inferencia de tipos? Pues que si no devolvemos alguna de las propiedades de la interface o las cambiamos, no se quejaría porque no está enlazada a ella y no es capaz de saber que realmente queremos retornar un objeto con un modelo concreto. En este momento, está trabajando la inferencia de tipos y no tenemos forma de cumplir una interface.

image.png

¿Cómo le decimos a TypeScript que queremos retornar, específicamente, un objeto con nuestro modelo? Por ejemplo, mediante la aserción de tipos.

Aserción de tipos

La aserción de tipos es cuando utilizamos la palabra clave as para decirle que ese valor es de X tipo.

Fíjate cómo añandiendo ahora en el retorno de la función as Product, cuando vamos a utilizar el objeto product nos dice que no está anotherProp disponible.

const getProduct = () => ({
  id: 1,
  author: 'irrelevant author',
  title: 'irrelevant title',
  price: '45.00',
  anotherProp: 'irrelevant prop',
- })
+ }) as Product

image.png

Es más, ahora si quitamos una propiedad que cumpla la interface, se quejará nuestro código (comentamos la línea del precio del producto):

const getProduct = () => ({
  id: 1,
  author: 'irrelevant author',
  title: 'irrelevant title',
  // price: '45.00',  <- ERROR: Property 'price' is missing in type '{ id: number; author: string; title: string; anotherProp: string; }' but required in type 'Product'.
  anotherProp: 'irrelevant prop'
}) as Product

Pero... ¿por qué no nos arroja un error sobre anotherProp si esa propiedad no es parte de nuestro modelo? Porque con la aserción de tipos las propiedades de más no influyen en nuestro modelo a nivel de tipos, porque si lo piensas, una vez vayas a usar el objeto, TypeScript no te mostrará esa propiedad disponible y sería como si no fuera a afectar a nuestro código.

Pero si realmente queremos que nos diga que esa propiedad no es parte de nuestro modelo y nos queremos poner estrictos, usaremos la anotación literal de tipos.

Anotación literal de tipos

- const getProduct = () => ({
+ const getProduct = (): Product => ({
  id: 1,
  author: 'irrelevant author',
  title: 'irrelevant title',
  price: '45.00',
  anotherProp: 'irrelevant prop',
})

image.png

ERROR: Type '{ id: number; author: string; title: string; price: string; anotherProp: string; }' is not assignable to type 'Product'.

Además, recuerda que esta es la forma aconsejada de tipar tus funciones por razones obvias.

Ejemplo en la vida real

¿Y saber estas diferencias para qué nos podrían servir? Pues voy a poner un ejemplo.

Últimamente estoy trabajando en un proyecto personal con Vue 3 y TypeScript, y buscando documentación y ejemplos sobre cómo tipar estructuras como el data de Vue, me encuentro con que siempre utilizan la aserción de tipos de TypeScript.

Veamos en un ejemplo qué problemas tiene usar este modelo de tipos.

Para ello, continuaremos con la definición de las interfaces anteriores para Product. Para quitar ruido, las tendremos en un fichero de tipos dentro de nuestra aplicación.

// src/types/index.ts

type Price = string;
type ProductId = number;

export interface Product {
  id: ProductId;
  author: string;
  title: string;
  price: Price;
}

Y ahora definiremos un componente que usará nuestro modelo.

Este componente tendrá una propiedad data que será donde tendremos nuestro listado de productos y el id del producto seleccionado.

<script lang="ts">
import { defineComponent } from "vue";
import { Product } from "@/types";

export default defineComponent({
  data() {
    return {
      list: [],
      selectedProductId: 0,
    }
  }
});
</script>

Lo ideal sería definir una interface para declarar este Data. Mi intención sería la siguiente:

interface Data {
  list: Product[];
  selectedProductId: Product["id"];
}

Primero, veremos cómo suelen declarar este data en los ejemplos que he visto:

// Forma común
export default {
  data() {
    return {
      list: [] as Product[],
      selectedProductId: 0
    }
  }
}

// Otra forma
export default {
  data() {
    return {
      list: [],
      selectedProductId: 0,
    } as unknown as Data;
  }
}

El problema que tiene la aserción de tipos es que tú puedes decirle que un valor será del tipo Product, pero en la inicialización pasarle un aguacate, que no se quejará de nada, porque le estás forzando el tipado. Le estás diciendo a TypeScript que confíe en ti 😅

Otro problema que tiene es que no tienes forma de unir la definición de tu interface Data de forma real. Si cambias algo, debes acordarte de cambiarlo en el data porque no te avisará de los errores.

Entonces, ¿qué podemos hacer? En este caso, el data de nuestro componente no es más que una función que retornará un objeto reactivo. Olvidemos lo de reactivo y centrémonos en una función que retorna un objeto.

Si queremos que nuestro data trabaje como anotación de tipos literales y no como aserción o inferencia, ¿por qué simplemente no le decimos el retorno que debe llevar esa función?

export default {
 data(): Data {
    return {
      list: [],
      selectedProductId: 0,
    }
  }
}

Y de esta forma, si agregamos alguna propiedad o modificamos algo en nuestra interface Data, nos arrojará un error en la definición del data del componente

interface Data {
  list: Product[];
  selectedProductId: Product["id"];
+  anotherProp: string;
}

image.png

En resumen:

¡Deja de usar as tal cosa! De hecho... creo que sólo en los reduce de creación es cuando único puede tener sentido una aserción de tipos. De resto, puede resultar un mal olor de tus tipos.