Variables, constantes y tipos primitivos
Lección 7 de 43 · El lenguaje: de JavaScript a TypeScript estricto
Con esta lección empieza el bloque del sistema de tipos, y el punto de partida es el más pequeño posible: declarar una variable. Parece trivial, pero la forma de declararla decide dos cosas distintas. Qué se puede reasignar, y qué tipo infiere el compilador. Ambas tienen consecuencias que se arrastran por el resto del código. Nota sobre la terminología: en este artículo, declarar significa crear una variable con let, const o var. Asignar significa darle un valor, y reasignar significa cambiar el valor al que apunta una variable ya declarada.

De JavaScript a TypeScript estrictoLección 7/7
Temario del curso
De JavaScript a TypeScript estricto
Lección 7 de 7
CursoDe JavaScript a TypeScript estricto · Lección 7 de 7
Alcance y elevación: let, const y var
Las tres palabras clave declaran variables, pero se comportan de forma distinta en tres aspectos. Vamos uno por uno.
Alcance
Definición — Alcance (scope): La región del código donde una variable es visible. Un bloque es cualquier par de llaves: el cuerpo de un if, de un bucle o de una función.
let y const viven en el bloque donde se declaran. var vive en la función entera, sin importar cuántos bloques haya en medio.
ts
function ejemplo(condicion: boolean) {
if (condicion) {
var conVar = 'a';
let conLet = 'b';
}
conVar; // ✅ accesible: `var` ignora el bloque
conLet; // ❌ error: fuera del bloque donde se declaró
}Elevación
Las tres declaraciones se registran antes de ejecutar el código, pero no de la misma forma. var se inicializa a undefined, mientras que let y const no se inicializan hasta llegar a su línea.
ts
console.log(conVar); // undefined: no falla, y eso es lo malo
var conVar = 1;
console.log(conLet); // ❌ ReferenceError: Cannot access 'conLet' before initialization
let conLet = 1;Leer un var antes de asignarlo devuelve undefined en silencio. Leer un let antes lanza un error que dice exactamente qué pasó.
Redeclaración
var permite declarar dos veces el mismo nombre en el mismo ámbito sin protestar, lo que hace que una variable se pise sin aviso. let y const lo prohíben.
Resumen
varletconstAlcanceFunción enteraBloqueBloqueAntes de su líneaundefinedErrorError¿Se puede redeclarar?SíNoNo¿Se puede reasignar?SíSíNo
El caso que resume por qué var sobra
El bucle. Con var, todas las iteraciones comparten la misma variable. Con let, cada iteración tiene la suya:
ts
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3, 3, 3
for (let j = 0; j < 3; j++) setTimeout(() => console.log(j)); // 0, 1, 2Esto conecta con las closures de la lección 3: la función guardada captura la variable, y con var esa variable es una sola y compartida. Para cuando los callbacks se ejecutan, ya vale 3.
La regla práctica:
constpor defecto,letcuando haga falta reasignar,varnunca en código nuevo. Empezar siempre porconsthace que cada reasignación sea una decisión consciente, y en la práctica la mayoría de las variables no se reasignan nunca.
const protege el binding, no el contenido
Este es el malentendido más común del lenguaje, y conviene desarmarlo con precisión.
Definición — Binding: La asociación entre un nombre de variable y el valor al que apunta. const congela esa asociación, no el valor en sí.
const impide reasignar la variable, es decir, hacer que el nombre apunte a otro valor. No dice nada sobre el valor:
ts
const user = { id: 1 };
user.id = 2; // ✅ permitido: const protege el binding, no el contenido
user = { id: 3 }; // ❌ error: eso sí es reasignarCon primitivos la distinción no se nota, porque un número o un texto no tienen partes que modificar: la única forma de cambiarlos es reasignar, y eso está prohibido. Con objetos y arrays sí se nota, y mucho:
ts
const lista = ['a'];
lista.push('b'); // ✅ permitido
lista.length = 0; // ✅ permitido
lista = []; // ❌ errorLas dos formas de congelar el contenido
Definición — Object.freeze: Función que impide modificar las propiedades de un objeto en tiempo de ejecución. Es superficial: congela el primer nivel, y los objetos anidados siguen siendo modificables.
ts
const config = Object.freeze({ reintentos: 3, limites: { max: 10 } });
config.reintentos = 5; // no hace nada, o lanza en modo estricto
config.limites.max = 20; // ✅ funciona: el nivel interno no está congeladoDefinición — readonly: Modificador de TypeScript que impide reasignar una propiedad en tiempo de compilación. No genera código: desaparece al compilar.
No son alternativas, resuelven el mismo problema en momentos distintos. En un servicio real suele bastar con readonly, porque el código que podría modificar el objeto es el propio. readonly tiene su propia lección más adelante.
Los tipos primitivos
Definición — Primitivo: Un valor que no es un objeto y no tiene partes modificables. La única forma de "cambiarlo" es crear otro.
El lenguaje tiene siete, aunque en un servicio típico solo aparecen cinco con frecuencia:
TipoEjemploNotasstring'hola'Cadenas de textonumber42, 3.14Un único tipo para enteros y decimalesbooleantrue—nullnullAusencia asignada a propósitoundefinedundefinedAusencia por defectosymbolSymbol('k')Claves únicas; poco frecuentebigint10nEnteros de precisión arbitraria
Tres precisiones que ahorran sorpresas.
No hay tipo entero
number es un número de coma flotante de doble precisión. Por eso 0.1 + 0.2 no da exactamente 0.3, y los enteros muy grandes pierden precisión a partir de cierto punto.
Para importes monetarios, la solución habitual es trabajar en la unidad mínima, los céntimos, con enteros; o usar una librería decimal.
null y undefined no son lo mismo
-
undefinedes lo que hay cuando nadie asignó nada: una variable declarada sin valor, un campo inexistente, el retorno de una función que no devuelve. -
nulles un valor que alguien puso a propósito para decir "aquí no hay nada".
La lección 5 mostró por qué la distinción importa al elegir operadores. Al modelar tipos importa igual, y conviene que el proyecto tenga un criterio y lo aplique.
Los primitivos tienen métodos, pero no son objetos
Escribir 'texto'.toUpperCase() funciona porque el motor envuelve el primitivo en un objeto solo para esa llamada y lo descarta después. Por eso los métodos de string nunca modifican el valor original: devuelven uno nuevo.
ts
const nombre = 'ana';
nombre.toUpperCase(); // 'ANA'
nombre; // 'ana' — sin cambiosTen en cuenta: los tipos en mayúscula (
String,Number,Boolean) son los constructores de objeto, no los primitivos. Anotar con ellos es casi siempre un error: se escribestring, noString.
Tipo literal frente a tipo ancho
Aquí está la parte propiamente de TypeScript, y la que más consecuencias tiene en el resto del curso.
Definición — Tipo literal: Un tipo cuyo único valor posible es un valor concreto. El tipo 'info' solo admite la cadena 'info'.
Definición — Ensanchamiento (widening): Que el compilador amplíe un tipo literal a su categoría general, de 'info' a string, porque el valor podría cambiar.
Cuando declaras una variable sin anotación, el compilador infiere su tipo a partir del valor. Pero infiere un tipo distinto según uses const o let:
ts
const nivel = 'info'; // tipo: 'info'
let otro = 'info'; // tipo: stringLa lógica es directa. Un const no puede reasignarse: su valor será 'info' durante toda su vida, así que el tipo más preciso es el literal. Un let sí puede reasignarse a cualquier otra cadena, así que lo más preciso que el compilador puede prometer es string.
Ocurre lo mismo con números y booleanos:
DeclaraciónTipo inferidoconst puerto = 30003000let contador = 0numberconst activo = truetruelet listo = trueboolean
Por qué esto importa
Que un tipo literal exista no es una curiosidad: es lo que permite al compilador comprobar conjuntos cerrados de valores.
ts
function log(nivel: 'info' | 'warn' | 'error'): void { /* ... */ }
const a = 'info';
let b = 'info';
log(a); // ✅ compila: el tipo de `a` es 'info'
log(b); // ❌ error: `b` es string, y string no encaja en esa uniónEse error sorprende la primera vez y es correcto: nada impide que b valga otra cosa antes de llegar a la llamada.
La excepción: dentro de un objeto
El ensanchamiento también ocurre en las propiedades de un objeto, aunque lo declares con const. La razón es la de la sección anterior: const protege el binding, no el contenido, así que las propiedades sí pueden reasignarse.
ts
const config = { nivel: 'info' }; // tipo: { nivel: string }
log(config.nivel); // ❌ error: es string, no 'info'Esto descoloca porque el objeto es const y el valor está a la vista. La forma de conservar los tipos literales es as const:
ts
const config = { nivel: 'info' } as const; // { readonly nivel: 'info' }
log(config.nivel); // ✅ compilaDefinición — as const: Aserción que congela la inferencia. Mantiene los tipos literales de todos los valores y marca todas las propiedades como readonly, en cascada por los niveles anidados.
Es una herramienta que reaparece constantemente: en catálogos de constantes, en tuplas, en configuraciones. Tiene su propio desarrollo en las lecciones de inferencia y de enum.
Otra forma de frenar el ensanchamiento: una anotación explícita, pero al revés.
let nivel: 'info' | 'warn' = 'info'declara el conjunto de valores admitidos y permite reasignar dentro de él. Es la opción correcta cuando la variable sí debe cambiar, pero solo entre valores conocidos.
El atajo para recordarlo
¿Puede este valor cambiar?
-
Si no puede, el compilador se queda con el valor exacto.
-
Si puede, se queda con la categoría.
-
Y un objeto siempre puede cambiar por dentro, salvo que le digas
as const.
Ejercicios
Ejercicio 1: predecir los tipos
Sin ejecutarlo, escribe qué tipo infiere el compilador para cada declaración y justifica cada respuesta.
ts
const a = 'ENVIADO';
let b = 'ENVIADO';
const c = { estado: 'ENVIADO' };
const d = { estado: 'ENVIADO' } as const;
const e = [1, 2, 3];Ejercicio 2: const que no protege
Escribe un fragmento donde una variable declarada con const cambie de contenido sin que el compilador proteste. Después escribe dos versiones que sí lo impidan: una que falle al compilar y otra que falle, o no haga nada, en ejecución.
Ejercicio 3: el error del tipo ancho
Explica por qué falla la última línea y ofrece tres formas distintas de arreglarlo sin cambiar la firma de configurar.
ts
function configurar(modo: 'lectura' | 'escritura'): void { /* ... */ }
const opciones = { modo: 'lectura' };
configurar(opciones.modo);Ejercicio 4: el bucle con var
Reproduce el bucle con var y setTimeout que imprime tres veces el mismo número. Después arréglalo de dos formas distintas: cambiando la declaración, y sin cambiarla pero creando un ámbito nuevo por iteración.
Pista: la segunda solución es la que se usaba antes de que existiera let, y se apoya en las closures de la lección 3.
Soluciones
Solución 1
DeclaraciónTipo inferidoPor quéconst a'ENVIADO'Un const no puede reasignarse: el tipo literal es lo más precisolet bstringPuede reasignarse a cualquier cadenaconst c{ estado: string }const no impide reasignar la propiedad, así que se ensanchaconst d{ readonly estado: 'ENVIADO' }as const congela la inferencia y añade readonlyconst enumber[]Un array puede crecer y reordenarse, así que no se infiere como tupla
El caso e es el que más sorprende: ni siquiera con const se infiere [1, 2, 3]. Se ve en detalle en la lección siguiente.
Solución 2
ts
// const que no protege el contenido
const config = { reintentos: 3 };
config.reintentos = 5; // compila sin problema
// falla al compilar
const configTs: { readonly reintentos: number } = { reintentos: 3 };
configTs.reintentos = 5; // ❌ error: la propiedad es de solo lectura
// falla, o no hace nada, en ejecución
const configJs = Object.freeze({ reintentos: 3 });
configJs.reintentos = 5; // en modo estricto lanza; si no, se ignoraLa diferencia es cuándo aparece el aviso. readonly avisa mientras escribes; Object.freeze actúa cuando el programa ya corre.
Solución 3
Falla porque opciones es un objeto y sus propiedades se ensanchan pese al const, así que opciones.modo tiene tipo string, que no encaja en la unión 'lectura' | 'escritura'.
Las tres formas de arreglarlo:
ts
// 1. as const en el objeto
const opciones = { modo: 'lectura' } as const;
// 2. anotar el objeto con el tipo que espera la función
const opciones: { modo: 'lectura' | 'escritura' } = { modo: 'lectura' };
// 3. as const solo en el valor que lo necesita
const opciones = { modo: 'lectura' as const };La segunda es la más explícita y la que mejor documenta la intención. La primera es la más corta, pero marca todo el objeto como readonly, cosa que estorba si luego hay que modificarlo.
Solución 4
ts
// el problema
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 3, 3, 3
// solución 1: cambiar la declaración
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // 0, 1, 2
// solución 2: crear un ámbito nuevo por iteración
for (var i = 0; i < 3; i++) {
((copia: number) => setTimeout(() => console.log(copia)))(i);
} // 0, 1, 2En la segunda, la función que se invoca de inmediato recibe el valor actual de i como parámetro. Ese parámetro es una variable nueva en cada vuelta, así que cada closure captura la suya. Es exactamente lo que let hace por ti.
Conclusión
Declarar una variable decide dos cosas a la vez.
La primera es si se puede reasignar, y ahí la regla es const por defecto, sabiendo que protege el nombre y no el contenido.
La segunda, propia de TypeScript, es qué tipo infiere el compilador: un const conserva el valor exacto como tipo literal, un let se ensancha a la categoría, y las propiedades de un objeto se ensanchan siempre salvo que as const lo impida.
Esa diferencia, que aquí parece un detalle, es la base de las uniones de literales y los catálogos comprobados que aparecen a lo largo del curso.
Artículos relacionados

JSON.parse y la frontera sin tipos
Las cinco lecciones anteriores trataron datos que ya estaban dentro del programa. Esta trata del momento en que entran. JSON es el formato por el que llega casi todo lo externo: el cuerpo de una petición HTTP, el mensaje de una cola, la respuesta de un servicio de terceros, un archivo de configuración. Y JSON.parse es la puerta por la que pasa. Esa puerta tiene una particularidad incómoda: el compilador no puede comprobar nada de lo que la cruza, y por defecto tampoco avisa de que no puede. Nota sobre la terminología: en este artículo, serializar significa convertir un valor a texto JSON, y parsear significa el proceso inverso. Frontera del sistema es cualquier punto donde entran datos que el programa no generó.

Operadores de ausencia: ?., ?? y ??=
Lección 5 de 43 · El lenguaje: de JavaScript a TypeScript estricto

Recorrer datos: map, filter, find, reduce y Object.*
Lección 4 de 43 · El lenguaje: de JavaScript a TypeScript estricto