Píldoras TypeScript: type narrowing con "as const"
Kus Cámara
Posted on August 3, 2024
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"
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!!!",
}
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>'.
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.
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
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.
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
}
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
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.
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'
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,
}
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".
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',
})
Dado que Object.freeze
devuelve un objeto de solo lectura, TypeScript infiere el tipo literal para sus propiedades.
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
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 😊
Posted on August 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.