Dónde vive cada variable y por qué importa
Cada vez que escribes el nombre de una variable en JavaScript, el motor tiene que responder una pregunta concreta: ¿a qué valor se refiere este nombre en este punto del programa? La respuesta no depende del azar ni del orden en que se ejecutan las funciones, sino de una regla muy precisa que el lenguaje aplica de forma sistemática. Esa regla se conoce como alcance léxico, y entenderla es la diferencia entre adivinar por qué un código funciona y saberlo con certeza.
El alcance léxico está detrás de comportamientos que todo desarrollador JavaScript encuentra tarde o temprano: un ReferenceError inesperado, una variable que parece tener un valor antiguo, un bucle que imprime siempre el mismo número o una función que recuerda datos mucho después de haber terminado. También es la base de los closures, una de las herramientas más potentes del lenguaje y una pregunta habitual en entrevistas técnicas.
En este artículo vamos a construir el concepto paso a paso. Empezaremos por qué es el alcance y qué tipos existen, seguiremos con la cadena de alcance y el sombreado de variables, aclararemos qué significa exactamente “léxico” y lo compararemos con el alcance dinámico. Después veremos cómo el alcance léxico da lugar a los closures, qué relación tiene con las funciones flecha y this, y terminaremos con los errores más frecuentes y buenas prácticas para código moderno.
Qué es el alcance de una variable
El alcance, o scope, es la región del código en la que un identificador es visible y puede utilizarse. Fuera de esa región, el nombre simplemente no existe para el motor. Esta idea permite que dos partes de un programa usen el mismo nombre sin interferir entre sí y que los datos internos de una función queden protegidos del resto del código.
Un ejemplo mínimo lo deja claro:
const empresa = "Nébula Labs";
function presentarEquipo() {
const lider = "Marta";
console.log(`${lider} dirige el equipo de ${empresa}`);
}
presentarEquipo();
console.log(lider);
Marta dirige el equipo de Nébula Labs
ReferenceError: lider is not defined
La constante empresa es visible tanto fuera como dentro de la función. La constante lider, en cambio, solo existe dentro de presentarEquipo. Al intentar leerla desde fuera, el motor no encuentra ningún identificador con ese nombre y lanza un ReferenceError.
Restringir la visibilidad no es una limitación, sino una ventaja. Reduce la posibilidad de que una parte del programa modifique por accidente datos de otra, facilita razonar sobre el código porque cada función tiene un conjunto acotado de nombres, y permite que el motor libere memoria cuando una variable deja de ser accesible.
Tipos de alcance en JavaScript moderno
JavaScript no tiene un único tipo de alcance, sino varios niveles que se anidan unos dentro de otros. Conocerlos todos es imprescindible para predecir dónde está disponible cada variable.
El alcance global es el nivel más externo. En un script clásico del navegador, las variables declaradas con var y las declaraciones de función en el nivel superior se convierten en propiedades del objeto global, mientras que let, const y class crean variables globales que no se añaden a ese objeto:
var version = "1.0";
let entorno = "producción";
console.log(globalThis.version);
console.log(globalThis.entorno);
1.0
undefined
El objeto globalThis es la forma estándar y portable de referirse al objeto global, tanto en navegadores como en Node.js, en sustitución de window o global.
El alcance de módulo aparece cuando el archivo se carga como módulo ES, ya sea con <script type="module"> en el navegador o con archivos .mjs o "type": "module" en Node.js. En ese caso, las variables del nivel superior pertenecen al módulo y no al objeto global. Solo son visibles fuera si se exportan explícitamente:
// config.mjs
const claveInterna = "no-exportada";
export const tiempoEspera = 5000;
// app.mjs
import { tiempoEspera } from "./config.mjs";
console.log(tiempoEspera);
console.log(typeof claveInterna);
5000
undefined
El alcance de función se crea cada vez que se ejecuta una función. Sus parámetros y todas las variables declaradas dentro, con cualquier palabra clave, quedan confinados a esa ejecución.
El alcance de bloque se crea con cualquier par de llaves: un if, un for, un while, un try o incluso un bloque suelto. Las variables declaradas con let, const y class dentro de un bloque solo existen en él. Las declaradas con var lo ignoran y se asocian a la función más cercana:
function revisarStock(unidades) {
if (unidades < 10) {
var aviso = "Stock bajo";
let urgente = unidades === 0;
}
console.log(aviso);
console.log(typeof urgente);
}
revisarStock(3);
Stock bajo
undefined
Este comportamiento de var es una de las razones por las que el alcance de bloque con let y const se considera hoy la opción por defecto: las variables viven exactamente donde se necesitan y no se filtran a zonas del código donde no tienen sentido.
La cadena de alcance paso a paso
Como los alcances se anidan, una función puede acceder no solo a sus propias variables, sino también a las de todos los alcances que la contienen. El mecanismo que hace posible ese acceso es la cadena de alcance en JavaScript, también llamada scope chain.
Cuando el motor encuentra un identificador, sigue siempre el mismo procedimiento:
- Busca el nombre en el alcance actual.
- Si no lo encuentra, pasa al alcance que lo contiene directamente.
- Repite el proceso hacia fuera, nivel por nivel.
- Si llega al alcance global y tampoco lo encuentra, lanza un
ReferenceError.
La búsqueda siempre va de dentro hacia fuera, nunca al revés. Un alcance externo jamás puede ver las variables de uno interno.
const plataforma = "TiendaGo";
function crearPedido() {
const pedidoId = "P-2041";
function calcularEnvio() {
const coste = 4.99;
function resumen() {
return `${plataforma} · ${pedidoId} · envío ${coste} €`;
}
return resumen();
}
return calcularEnvio();
}
console.log(crearPedido());
TiendaGo · P-2041 · envío 4.99 €
La función resumen no declara ninguna variable propia. Para resolver coste, el motor sube un nivel hasta calcularEnvio. Para pedidoId, sube dos niveles hasta crearPedido. Para plataforma, recorre toda la cadena hasta el alcance global.
Podemos representar esa estructura como un árbol en el que cada nivel puede ver a sus ancestros, pero no a sus descendientes:
Alcance global: plataforma
└── crearPedido: pedidoId
└── calcularEnvio: coste
└── resumen: (sin variables propias)
Sombreado de variables: cuando dos nombres coinciden
La búsqueda en la cadena se detiene en cuanto encuentra el primer identificador con el nombre buscado. Si un alcance interno declara una variable con el mismo nombre que otra de un alcance externo, la interna oculta a la externa. Este fenómeno se llama sombreado, o shadowing:
const moneda = "USD";
function mostrarPrecio() {
const moneda = "EUR";
function formatear(importe) {
const moneda = "MXN";
return `${importe} ${moneda}`;
}
console.log(formatear(250));
console.log(moneda);
}
mostrarPrecio();
console.log(moneda);
250 MXN
EUR
USD
Cada console.log resuelve moneda en el alcance más cercano que la define. Ninguna de las tres declaraciones modifica a las demás: son tres variables distintas que comparten nombre.
El sombreado es legal y a veces útil, pero suele dificultar la lectura y es una fuente habitual de errores, porque es fácil creer que se está modificando la variable externa cuando en realidad se trabaja con una interna. Por eso herramientas como ESLint incluyen la regla no-shadow, que avisa de estos casos:
// eslint.config.js
export default [
{
rules: {
"no-shadow": "warn",
},
},
];
Un caso en el que el sombreado no está permitido es la redeclaración en el mismo alcance. Declarar dos veces el mismo nombre con let o const dentro del mismo bloque produce un error de sintaxis antes incluso de ejecutar el código:
SyntaxError: Identifier 'moneda' has already been declared
Qué significa realmente léxico
La palabra léxico se refiere al texto del código fuente tal como está escrito. Durante el análisis léxico, el motor lee el programa y determina dónde se declara cada identificador. Esa estructura de anidamiento queda fijada en ese momento y no cambia durante la ejecución.
El alcance léxico, también llamado alcance estático, establece que el alcance de una función depende del lugar del código donde fue definida, no del lugar desde donde se la llama. Esa es la idea central de todo el artículo, y conviene verla con un ejemplo que la ponga a prueba:
const saludo = "Hola desde el global";
function decirSaludo() {
return saludo;
}
function ejecutar() {
const saludo = "Hola desde ejecutar";
return decirSaludo();
}
console.log(ejecutar());
Hola desde el global
Aunque decirSaludo se invoca desde dentro de ejecutar, donde existe otra variable saludo, la función devuelve el valor global. La razón es que decirSaludo fue escrita en el alcance global, y su cadena de alcance se construye a partir de ese lugar del texto. Desde dónde se la llame es irrelevante.
El mismo principio explica por qué una función no puede acceder a las variables de otra función hermana, aunque una invoque a la otra:
function obtenerApellido() {
const apellido = "Ferrer";
return apellido;
}
function nombreCompleto() {
const nombre = "Lucía";
return `${nombre} ${obtenerApellido()}`;
}
function nombreCompletoRoto() {
const nombre = "Lucía";
return `${nombre} ${apellido}`;
}
console.log(nombreCompleto());
console.log(nombreCompletoRoto());
Lucía Ferrer
ReferenceError: apellido is not defined
nombreCompleto funciona porque pide el valor a la función que lo conoce. nombreCompletoRoto falla porque apellido vive dentro de otra función, que no forma parte de su cadena de alcance.
Alcance léxico frente a alcance dinámico
Para apreciar mejor el alcance léxico conviene compararlo con su alternativa. En los lenguajes con alcance dinámico, las variables libres de una función se resuelven buscando en la pila de llamadas en el momento de la ejecución. Es decir, importa quién llamó a la función, no dónde se escribió.
Pocos lenguajes modernos usan alcance dinámico de forma predeterminada, pero hay un ejemplo muy cercano a cualquier desarrollador: las variables locales de bash. Este script reproduce el ejemplo anterior:
#!/usr/bin/env bash
saludo="Hola desde el global"
decir_saludo() {
echo "$saludo"
}
ejecutar() {
local saludo="Hola desde ejecutar"
decir_saludo
}
ejecutar
Hola desde ejecutar
El resultado es el contrario al de JavaScript. Como bash busca la variable en la cadena de llamadas, decir_saludo ve la variable local de ejecutar, que fue quien la invocó.
El alcance dinámico hace que el comportamiento de una función dependa del contexto en el que se usa, lo que complica razonar sobre el código y reutilizar funciones con seguridad. El alcance léxico, en cambio, permite saber qué variables ve una función con solo leer el texto, sin ejecutar nada. Por eso es el modelo que adoptan la mayoría de los lenguajes actuales, como JavaScript, Python, Rust, Go o Java.
Curiosamente, JavaScript sí tiene un elemento con comportamiento parecido al dinámico: el valor de this en las funciones tradicionales, que depende de cómo se llama la función. Lo veremos más adelante.
Hoisting y la zona muerta temporal
El alcance léxico se establece antes de ejecutar el código, y eso tiene una consecuencia práctica: el motor conoce todas las declaraciones de un alcance antes de ejecutar su primera línea. Este fenómeno se conoce como hoisting o elevación.
Con var, la variable se crea al inicio del alcance y se inicializa con undefined, de modo que puede leerse antes de su declaración sin error:
console.log(descuento);
var descuento = 15;
console.log(descuento);
undefined
15
Con let, const y class, la variable también se registra al inicio del bloque, pero no se inicializa hasta que la ejecución llega a su declaración. El tramo entre el inicio del bloque y la declaración se llama zona muerta temporal, y cualquier acceso dentro de ella lanza un error:
function calcularTotal() {
console.log(impuesto);
const impuesto = 0.21;
}
calcularTotal();
ReferenceError: Cannot access 'impuesto' before initialization
Observa que el mensaje es distinto al de una variable inexistente. El motor sabe que impuesto existe en ese alcance, porque lo registró de forma léxica, pero todavía no puede usarse.
La zona muerta temporal también explica un comportamiento que sorprende a muchos desarrolladores: una variable interna puede bloquear el acceso a una externa con el mismo nombre, incluso antes de su declaración:
const limite = 100;
function procesar() {
console.log(limite);
const limite = 50;
}
procesar();
ReferenceError: Cannot access 'limite' before initialization
Como procesar declara su propia limite, todo el cuerpo de la función queda asociado a esa declaración interna. La búsqueda no sube al alcance global, sino que encuentra la variable local todavía sin inicializar.
Las declaraciones de función se elevan completas, con su cuerpo incluido, por lo que pueden llamarse antes de aparecer en el código. Las expresiones de función y las funciones flecha asignadas a const siguen las reglas de la zona muerta temporal.
Closures: el alcance léxico que sobrevive
Si el alcance léxico se define al escribir el código, ¿qué ocurre cuando una función interna sigue existiendo después de que la función externa haya terminado? JavaScript resuelve esa situación de forma elegante: la función interna conserva una referencia a su entorno léxico completo. Esa combinación de una función con las variables de su entorno se llama closure o clausura.
function crearContador(inicio = 0) {
let cuenta = inicio;
return function incrementar() {
cuenta += 1;
return cuenta;
};
}
const visitas = crearContador();
const descargas = crearContador(100);
console.log(visitas());
console.log(visitas());
console.log(descargas());
1
2
101
Cuando crearContador termina, su variable cuenta debería desaparecer. Sin embargo, la función devuelta sigue haciendo referencia a ella, de modo que el motor la mantiene viva. Cada llamada a crearContador crea un entorno nuevo, por eso visitas y descargas tienen contadores independientes.
Los closures en JavaScript no son una característica añadida al lenguaje, sino la consecuencia natural del alcance léxico combinado con funciones de primera clase. Toda función de JavaScript es, técnicamente, un closure sobre el entorno en el que se creó.
Uno de sus usos más importantes es la encapsulación de datos privados. Antes de que existieran los campos privados de clase, era la forma estándar de ocultar estado, y sigue siendo muy común en código funcional:
function crearCuenta(titular) {
let saldo = 0;
const movimientos = [];
return {
depositar(importe) {
if (importe <= 0) throw new RangeError("Importe no válido");
saldo += importe;
movimientos.push({ tipo: "deposito", importe });
},
consultar() {
return `${titular}: ${saldo} €`;
},
historial() {
return [...movimientos];
},
};
}
const cuenta = crearCuenta("Andrés");
cuenta.depositar(120);
console.log(cuenta.consultar());
console.log(cuenta.saldo);
Andrés: 120 €
undefined
No hay forma de modificar saldo desde fuera salvo a través de los métodos que la propia función expone. Hoy las clases ofrecen una alternativa nativa con los campos privados, que usan el prefijo #:
class Cuenta {
#saldo = 0;
depositar(importe) {
this.#saldo += importe;
}
get saldo() {
return this.#saldo;
}
}
Ambas técnicas son válidas. Las clases suelen ser más claras cuando hay herencia o muchas instancias con métodos compartidos; los closures encajan mejor en estilos funcionales y en utilidades pequeñas.
Casos de uso prácticos de los closures
Más allá de los contadores, los closures aparecen constantemente en código real, muchas veces sin que lo notemos.
Las fábricas de funciones permiten generar variantes configuradas de una misma lógica:
function crearFormateador(locale, moneda) {
const formato = new Intl.NumberFormat(locale, {
style: "currency",
currency: moneda,
});
return (importe) => formato.format(importe);
}
const enEuros = crearFormateador("es-ES", "EUR");
const enPesos = crearFormateador("es-MX", "MXN");
console.log(enEuros(1234.5));
console.log(enPesos(1234.5));
1234,50 €
$1,234.50
El objeto Intl.NumberFormat se crea una sola vez y queda capturado por el closure, lo que además evita recrearlo en cada llamada.
La técnica de debounce, muy usada en buscadores con autocompletado, depende de un closure que recuerda el temporizador pendiente entre llamadas:
function debounce(fn, espera = 300) {
let temporizador;
return (...args) => {
clearTimeout(temporizador);
temporizador = setTimeout(() => fn(...args), espera);
};
}
const buscar = debounce((texto) => console.log("Buscando:", texto), 250);
buscar("jav");
buscar("java");
buscar("javascript");
Buscando: javascript
La memoización almacena resultados ya calculados en una caché privada:
function memoizar(fn) {
const cache = new Map();
return (n) => {
if (!cache.has(n)) cache.set(n, fn(n));
return cache.get(n);
};
}
const cuadradoLento = memoizar((n) => {
for (let i = 0; i < 1e7; i++);
return n * n;
});
Los manejadores de eventos también capturan variables de su entorno para recordar a qué elemento o dato pertenecen, y los hooks de librerías como React se basan por completo en closures que se crean en cada renderizado.
Funciones flecha, this y alcance léxico
Hasta ahora hemos hablado de variables, pero existe otro identificador cuyo comportamiento confunde a muchos desarrolladores: this. En las funciones tradicionales, this no sigue el alcance léxico. Su valor depende de cómo se invoca la función:
const temporizador = {
segundos: 0,
iniciar() {
setTimeout(function () {
this.segundos += 1;
console.log(this.segundos);
}, 100);
},
};
temporizador.iniciar();
NaN
El callback se ejecuta como una función independiente, así que su this no es el objeto temporizador. En un script no estricto apunta al objeto global; en modo estricto sería undefined y la operación lanzaría un error. En Node.js, setTimeout incluso fija this en el objeto del temporizador interno.
Las funciones flecha resuelven este problema porque no tienen su propio this. Lo toman del alcance léxico que las rodea, igual que cualquier otra variable:
const temporizador = {
segundos: 0,
iniciar() {
setTimeout(() => {
this.segundos += 1;
console.log(this.segundos);
}, 100);
},
};
temporizador.iniciar();
1
Lo mismo ocurre con arguments, super y new.target, que las funciones flecha heredan de su entorno. Por esta razón, las funciones flecha son ideales para callbacks y funciones internas, pero no deben usarse como métodos de objetos cuando se necesita que this apunte al propio objeto.
El clásico problema de var en los bucles
El ejemplo más famoso de la interacción entre alcance y closures es un bucle que programa varias tareas:
for (var i = 1; i <= 3; i++) {
setTimeout(() => console.log(`Tarea ${i}`), 10);
}
Tarea 4
Tarea 4
Tarea 4
Como var no respeta el alcance de bloque, existe una sola variable i para todo el bucle. Los tres callbacks capturan esa misma variable, y cuando se ejecutan, el bucle ya terminó y su valor es 4.
Con let, la especificación crea una nueva variable por cada iteración, de modo que cada closure captura su propio valor:
for (let i = 1; i <= 3; i++) {
setTimeout(() => console.log(`Tarea ${i}`), 10);
}
Tarea 1
Tarea 2
Tarea 3
Antes de let, la solución habitual era una función invocada inmediatamente que creaba un alcance nuevo en cada vuelta. Hoy basta con usar let, o métodos como forEach, que ejecutan una función distinta por elemento.
Errores comunes y cómo depurarlos
La mayoría de los problemas relacionados con el alcance producen síntomas reconocibles.
El error ReferenceError: x is not defined indica que el nombre no existe en ninguna parte de la cadena de alcance. Las causas típicas son errores tipográficos, intentar leer una variable de una función hermana o usar fuera de un bloque una variable declarada con let o const dentro de él.
El error ReferenceError: Cannot access 'x' before initialization señala un acceso dentro de la zona muerta temporal. Revisa si la variable se usa antes de su declaración o si una declaración interna está ocultando una externa con el mismo nombre.
Las variables globales accidentales aparecen al asignar un valor a un nombre no declarado en código no estricto. JavaScript crea entonces una propiedad global silenciosamente:
function registrar() {
usuarioActual = "admin"; // falta let o const
}
registrar();
console.log(globalThis.usuarioActual);
admin
En modo estricto, que está activo automáticamente en módulos ES y en el cuerpo de las clases, esa asignación lanza un ReferenceError. Es una de las mejores razones para trabajar siempre con módulos.
Los closures obsoletos, conocidos como stale closures, ocurren cuando una función captura una variable en un momento dado y sigue usando ese valor aunque el estado haya cambiado. Son frecuentes en temporizadores y en componentes de React con dependencias incompletas en useEffect. La solución pasa por leer el valor actual en el momento de usarlo o recrear la función cuando cambian sus dependencias.
Las fugas de memoria también pueden tener su origen en closures. Un manejador de eventos que nunca se elimina mantiene vivas todas las variables de su entorno, incluidos objetos grandes que ya no se usan. Eliminar los listeners con removeEventListener o con un AbortController permite que el recolector de basura libere esa memoria:
const controlador = new AbortController();
window.addEventListener("resize", () => recalcularDiseño(datosGrandes), {
signal: controlador.signal,
});
// Al desmontar la vista
controlador.abort();
Para depurar, las herramientas de desarrollo de los navegadores y el inspector de Node.js muestran el panel de alcance al detener la ejecución en un punto de interrupción. Allí aparecen, separados, los alcances local, de closure, de módulo y global, con sus variables y valores en ese instante.
Construcciones que rompen el alcance léxico
JavaScript conserva dos mecanismos heredados que permiten alterar el alcance en tiempo de ejecución, y ambos deben evitarse.
La sentencia with añade las propiedades de un objeto como si fueran variables del alcance, de modo que no se puede saber, leyendo el código, si un nombre se refiere a una variable o a una propiedad. Está prohibida en modo estricto y, por tanto, en módulos y clases:
SyntaxError: Strict mode code may not include a with statement
La función eval, usada de forma directa, ejecuta texto como código dentro del alcance actual y puede crear o leer variables locales de forma imprevisible. Además de dificultar la lectura, impide que el motor optimice la función y supone un riesgo de seguridad si el texto procede de una fuente externa. En modo estricto, las variables declaradas dentro de eval quedan confinadas a su propio alcance, lo que reduce el problema, pero lo recomendable es no usarla.
Ambas construcciones existen solo por compatibilidad. El código moderno, escrito como módulos ES, queda protegido de ellas de forma automática.
Buenas prácticas para trabajar con el alcance
Después de revisar todos los conceptos, estas pautas ayudan a escribir código más predecible:
- Usa
constpor defecto yletsolo cuando necesites reasignar. Evitavaren código nuevo. - Declara cada variable en el alcance más pequeño posible, cerca de donde se usa.
- Organiza el código en módulos ES para evitar contaminar el alcance global y activar el modo estricto.
- Evita el sombreado de variables y activa la regla
no-shadowen tu linter. - Prefiere funciones flecha para callbacks que necesiten el
thisdel entorno. - Ten presente qué capturan tus closures, especialmente si mantienen objetos grandes o viven mucho tiempo.
- No uses
withnieval.
Si usas TypeScript, el compilador aplica exactamente las mismas reglas de alcance que JavaScript y añade comprobaciones adicionales, como detectar variables usadas antes de asignarse, lo que ayuda a atrapar muchos de estos errores antes de ejecutar el código.
Conclusiones
El alcance léxico es la regla que decide qué variables puede ver cada parte de un programa JavaScript. Su principio es sencillo: lo que importa es dónde está escrito el código, no desde dónde se ejecuta. Gracias a ello, basta con leer el texto para saber qué identificadores tiene disponibles una función.
A partir de esa regla se construye todo lo demás. Los alcances global, de módulo, de función y de bloque se anidan formando una cadena que el motor recorre de dentro hacia fuera para resolver cada nombre. El sombreado aparece cuando dos niveles comparten nombre, y la zona muerta temporal protege a let y const de usarse antes de su declaración.
Los closures son la consecuencia más poderosa del alcance léxico: permiten que una función recuerde su entorno después de que este haya terminado, lo que habilita la encapsulación de datos, las fábricas de funciones, el debounce o la memoización. Las funciones flecha extienden la misma lógica a this, simplificando los callbacks.
Dominar estos conceptos no solo ayuda a superar preguntas de entrevista. Permite diagnosticar en segundos errores como un ReferenceError inesperado, un bucle que repite el mismo valor o un closure obsoleto, y escribir código moderno más claro, seguro y fácil de mantener.
