When you start learning TypeScript (or JavaScript), one of the very first questions you face is: should I use var, let, or const? They all declare variables, but they behave very differently in terms of scope, hoisting, and re-assignment. Getting these rules wrong leads to subtle bugs that are notoriously hard to track down. In this guide you will understand every difference with concrete examples, see the TypeScript compiler’s role, and learn the simple rules that modern TypeScript developers follow every day.
Quick Comparison Table
| Feature | var | let | const |
|---|---|---|---|
| Scope | Function | Block | Block |
| Hoisted? | Yes (value = undefined) | Yes (but in Temporal Dead Zone) | Yes (but in Temporal Dead Zone) |
| Re-declarable in same scope? | Yes | No | No |
| Re-assignable? | Yes | Yes | No |
| Recommended? | Avoid in modern TS | Use when value changes | Default choice |
var — Function Scope and Hoisting
var is the original JavaScript variable declaration. Its scope is the nearest enclosing function (or global if there is no function), not the nearest block. This makes it behave unexpectedly inside loops and conditional blocks.
// var leaks out of the if-block
function varScopeDemo() {
if (true) {
var message = "Hello from inside the block";
}
// 'message' is still accessible here because var is function-scoped
console.log(message); // "Hello from inside the block"
}
// var in a loop
for (var i = 0; i < 3; i++) {
// nothing here
}
console.log(i); // 3 (var 'i' leaks out of the for loop!)
// var hoisting
console.log(x); // undefined (no error! declaration was hoisted)
var x = 10;
console.log(x); // 10
// Re-declaration is allowed with var (dangerous)
var count = 1;
var count = 2; // no error!
console.log(count); // 2
The classic bug caused by var in loops:
let — Block Scope
Introduced in ES6, let is block-scoped: it lives and dies within the nearest pair of curly braces. It fixes both the loop closure bug and the accidental re-declaration bug.
// let is block-scoped
function letScopeDemo() {
if (true) {
let message = "Hello from the block";
console.log(message); // "Hello from the block"
}
// console.log(message); // ERROR: message is not defined outside the block
}
// let in a loop (the closure bug is fixed!)
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 100);
}
// Prints: 0, 1, 2 (each iteration gets its own block-scoped 'j')
// Temporal Dead Zone (TDZ): accessing let before its declaration throws
// console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 5;
// Re-declaration is a compile-time error in TypeScript
let score = 100;
// let score = 200; // ERROR: Cannot redeclare block-scoped variable 'score'
// Re-assignment is fine
let name = "Alice";
name = "Bob"; // OK
const — Block Scope + No Re-assignment
const has the same block-scope and TDZ behaviour as let, with one additional rule: the binding cannot be re-assigned after initialisation. This does not mean the value is deeply immutable — if you assign an object or array to a const, you can still mutate its contents.
// const: binding cannot be re-assigned
const PI = 3.14159;
// PI = 3; // ERROR: Cannot assign to 'PI' because it is a constant
// const must be initialised at declaration
// const MAX; // ERROR: 'const' declarations must be initialized
// const with objects: the BINDING is constant, not the contents
const user = { name: "Alice", age: 30 };
user.age = 31; // OK — mutating a property is allowed
console.log(user.age); // 31
// user = { name: "Bob" }; // ERROR: Cannot assign to 'user'
// const with arrays: same rule
const numbers: number[] = [1, 2, 3];
numbers.push(4); // OK
console.log(numbers); // [1, 2, 3, 4]
// numbers = [5, 6, 7]; // ERROR: Cannot assign to 'numbers'
// Use Object.freeze() for shallow deep immutability
const config = Object.freeze({ timeout: 3000, retries: 3 });
// config.timeout = 5000; // Silently fails in JS, TypeError in strict mode
TypeScript-Specific Behaviour: Narrowed Types
TypeScript infers a literal type for const primitives, which is narrower than the general type inferred for let:
const direction = "north"; // type is the literal type "north" (not string)
let heading = "north"; // type is string (can be reassigned to any string)
// This matters with union types
type Direction = "north" | "south" | "east" | "west";
function move(d: Direction) { console.log("Moving", d); }
move(direction); // OK: TypeScript knows direction is "north" (a valid Direction)
// move(heading); // ERROR: Argument of type 'string' is not assignable to
// parameter of type 'Direction'
When to Use Each
- Use
constby default. If you do not know upfront that you need to reassign, declare itconst. This signals intent and enables the TypeScript compiler’s literal type narrowing. - Use
letwhen you need to reassign. Loop counters, accumulators, and flag variables are natural candidates. - Avoid
var. There is no situation in modern TypeScript wherevaris preferable toletorconst. Enable the ESLintno-varrule to enforce this in your project.
Sample Output
-- var loop (buggy) --
3
3
3
-- let loop (correct) --
0
1
2
-- const object mutation --
user.age after mutation: 31
numbers after push: 1,2,3,4
See Also
- Mastering the Core of TypeScript: A Practical Guide to Types
- TypeScript Function Parameters: Rest, Optional, and Default
- Mastering TypeScript Union Types
- A Developer’s Guide to Truthy and Falsy in TypeScript
Conclusion
Understanding var, let, and const comes down to three axes: scope (function vs block), hoisting behaviour (TDZ vs undefined), and mutability (re-assignable vs not). The modern TypeScript rule is simple: reach for const by default, use let when you genuinely need to reassign, and never use var. Following this rule eliminates an entire class of JavaScript bugs and lets the TypeScript compiler give you the narrowest, most precise types possible.