Píldoras TypeScript: type narrowing con "as const"

kuscamara

Kus Cámara

Posted on August 3, 2024

Píldoras TypeScript: type narrowing con "as const"

En muchas ocasiones te habrás encontrado con un error de tipo "string no es asignable a... Whatever" al asignar a una variable un valor compatible con el tipo esperado. Esto ocurre cuando TypeScript infiere un tipo más amplio que el que se espera para una variable, como por ejemplo, cuando asignamos un string a una variable que debería ser de tipo literal.

Para entenderlo mejor, vamos a verlo con un ejemplo.

Tenemos un tipo Severity definido como un string unión de los valores: "low", "medium" y "high".

type Severity = "low" | "medium" | "high"
Enter fullscreen mode Exit fullscreen mode

Podríamos decir que Severity es un subtipo de string que, de todos los strings posibles, solo admite esos tres.

Por otra parte tenemos un objeto messages en el que las claves son un Severity y los valores son strings.

const messages: Record<Severity, string> = {
  low: "😕 not good",
  medium: "😖 ugly",
  high: "😱 AAAAHHH!!!",
}
Enter fullscreen mode Exit fullscreen mode

Queremos acceder a los valores de messages y para ello definimos una variable severity con el valor "low".

let severity = "low"

console.log(messages[severity])
//          ^? Error: Element implicitly has an 'any' type because
// expression of type 'string' can't be used to index type 
// 'Record<Severity, string>'.
Enter fullscreen mode Exit fullscreen mode

Aunque el valor que hemos asignado a la variable severity es compatible con el tipo Severity, TypeScript nos muestra un error que viene a decirnos que las claves de messages deben ser de tipo Severity y no de tipo string.

Para entender por qué ocurre esto, necesitamos entender cómo TypeScript infiere los tipos de las variables a las que no asignamos un tipo explícitamente.

Si hacemos hover en nuestro editor o en el Playground de TypeScript sobre severity veremos que TypeScript lo infiere como string.

Esto es así porque hemos declarado severity usando let y por lo tanto podemos reasignarle un valor que no sea compatible con Severity en cualquier momento.

Tipo string inferido para la variable severity declarada con let

En estos casos, TypeScript aplica lo que se conoce como "widening" y asigna a la variable el tipo más amplio que sea compatible con el valor que le hemos asignado inicialmente.

Ojo, digo "inicialmente" porque TypeScript espera que cambiemos el valor de una variable en runtime, pero no su tipo (de string a number, etc.). Si esta es nuestra intención, debemos especificarlo explícitamente.

No ocurre lo mismo si declaramos la variable con const.

const severity = "low"

console.log(messages[severity]) // OK
Enter fullscreen mode Exit fullscreen mode

Puesto que el uso de const no permite la reasignación de la variable, TypeScript infiere el tipo como el tipo literal "low" y el error desaparece.

Tipo literal

Por lo tanto, como regla general (con alguna excepción), TypeScript infiere el tipo más amplio para las variables de tipo primitivo declaradas con let y el tipo literal para las variables declaradas con const.

Esto en cuanto a las variables de tipo primitivo, pero ¿qué ocurre con los objetos?

Vamos a verlo con otro ejemplo.

Tenemos una interfaz Issue con estas propiedades:

interface Issue {
  id: string
  severity: Severity
}
Enter fullscreen mode Exit fullscreen mode

Y una función printIssue que recibe un objeto de tipo Issue y hace algo con él, que nos da igual.

declare function printIssue(issue: Issue): void
Enter fullscreen mode Exit fullscreen mode

Si pasamos a printIssue un objeto con una estructura compatible con Issue, TypeScript nos mostrará un error indicando que los tipos de severity no son compatibles.

const issue = {
  id: '123',
  severity: 'low',
}

printIssue(issue)
//         ^? Error: Argument of type '{ id: string; severity:
// string; }' is not assignable to parameter of type 'Issue'. 
// Types of property 'severity' are incompatible.          
Enter fullscreen mode Exit fullscreen mode

En el caso de los objetos, independientemente de que los declaremos con let o const, TypeScript infiere el tipo de sus propiedades como si hubieran sido declaradas con let.

Aunque no podamos asignar un nuevo valor a una variable de tipo objeto declarada con const, sí que podemos modificar sus propiedades.

issue.severity = 'other'
Enter fullscreen mode Exit fullscreen mode

Si llevamos un tiempo trabajando con TypeScript, sabremos que podemos solucionar este problema de varias formas, y una de ellas es usando as const sobre la propiedad incompatible.

const issue = {
  id: '123',
  severity: 'low' as const,
}
Enter fullscreen mode Exit fullscreen mode

Con el uso de as const le estamos indicando a TypeScript que infiera el tipo más pequeño posible (narrowing) para la propiedad severity en lugar del tipo más amplio string. En este caso, se infiere el tipo literal "low".

Uso de as const sobre la propiedad

Otra forma de solucionar este problema, a efectos didácticos, es usar Object.freeze sobre el objeto.

const issue = Object.freeze({
  id: '123',
  severity: 'low',
})
Enter fullscreen mode Exit fullscreen mode

Dado que Object.freeze devuelve un objeto de solo lectura, TypeScript infiere el tipo literal para sus propiedades.

Uso de Object.freeze sobre el objeto asignado a la variable issue

Object.freeze tiene una característica que puede ser un inconveniente en algunos casos, y es que la inmutabilidad que proporciona es "shallow", es decir, solo afecta a las propiedades del objeto, pero no a sus propiedades anidadas, que pueden seguir siendo modificadas.

Si nos interesa marcar un objeto como readonly en profundidad, podemos aplicar as const a todo el objeto.

const issue = {
  id: '123',
  severity: 'low',
  another: {
    prop: 'value'
  }
} as const
Enter fullscreen mode Exit fullscreen mode

Y esto es todo en cuanto a inferencia de tipos y as const como técnica de "narrowing", pero tenemos otras formas de evitar que TypeScript infiera tipos más amplios de los que esperamos, como por ejemplo, especificando el tipo de las variables explícitamente, usando satisfies o aprovechando el "excess property checking", pero eso lo dejamos para otra píldora.

Si has llegado hasta aquí, ¡gracias! Y si además te ha resultado útil o simplemente te ha gustado, ayúdame a llegar a más gente compartiéndolo 😊

💖 💪 🙅 🚩
kuscamara
Kus Cámara

Posted on August 3, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related