Variables #
Variables are containers for storing values — a concept shared by every programming language. But the way TypeScript handles variables differs fundamentally from JavaScript: TypeScript introduces static types that give every variable a “type identity” the compiler enforces for its entire lifetime. One of the most important decisions when writing TypeScript is choosing the right declaration keyword (let, const, or var) and understanding when type annotations should be written explicitly versus when they can be left to type inference. This article covers all aspects of variables in TypeScript in depth — including several traps that developers new to TypeScript from JavaScript often miss.
The Three Declaration Keywords: let, const, var
#
TypeScript inherits all three declaration keywords from modern JavaScript, but has a clear preference: const is the first choice, let for things that genuinely need to change, and var is almost never used anymore. Understanding why requires understanding scoping — the rules about where a variable can be accessed.
let — Block-Scoped, Mutable
#
let declares a variable whose scope is limited to the nearest { } block — whether it’s a function, if, loop, or empty block. let variables can be reassigned after declaration.
let usia: number = 25;
let nama: string = "Budi Santoso";
let aktif: boolean = true;
// Values can be changed — this is valid
usia = 26;
nama = "Budi";
// But the type cannot change
// usia = "dua puluh enam"; // ✗ Error: Type 'string' is not assignable to type 'number'
Block scope means a let variable doesn’t “leak” out of the block where it was declared:
function cekUsia(usia: number): void {
if (usia >= 18) {
let status = "dewasa"; // Only exists inside this if block
console.log(status); // ✓ "dewasa"
}
// console.log(status); // ✗ Error: Cannot find name 'status'
}
const — Block-Scoped, Cannot Be Reassigned
#
const declares a variable that cannot be reassigned — you can’t point a const to a new value after its initial declaration. This doesn’t mean its value is fully immutable, but the binding can’t change.
const PI: number = 3.14159;
const NAMA_APLIKASI: string = "MuslimApps";
// Cannot be reassigned
// PI = 3.14; // ✗ Error: Cannot assign to 'PI' because it is a constant
An important fact about const and objects: const only protects the variable’s reference, not the contents of the object or array it points to. Object properties and array elements can still be modified:
const pengguna = {
nama: "Budi",
usia: 25,
};
// CORRECT: Changing a property — doesn't violate const
pengguna.usia = 26;
pengguna.nama = "Budi Santoso";
// ANTI-PATTERN: Assuming the whole object is immutable because of const
// const guarantees pengguna always points to the same object,
// not that the object's contents can't change
// ✗ Error: This is what you can't do — reassign to a new object
// pengguna = { nama: "Siti", usia: 30 };
The same applies to arrays:
const angka: number[] = [1, 2, 3];
angka.push(4); // ✓ Allowed — modifying the array's contents
angka[0] = 99; // ✓ Allowed — changing an element
// angka = [10, 20, 30]; // ✗ Error — reassigning to a new array is not allowed
If you truly want an object that can’t be modified at all, use Object.freeze() or the Readonly<T> type:
// Readonly<T> — all properties become readonly at the TypeScript level
const pengaturan: Readonly<{ tema: string; bahasa: string }> = {
tema: "gelap",
bahasa: "id",
};
// pengaturan.tema = "terang"; // ✗ Error: Cannot assign to 'tema' because it is a read-only property
var — Function-Scoped, Should Be Avoided
#
var is the old declaration keyword from JavaScript with different scoping behavior — it’s bound to the function, not the block. This causes subtle, hard-to-track bugs, especially in loops and closures.
// Demonstration of the classic var vs let problem in loops + closures
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Output: 3, 3, 3 ← All show the final value of i, not the value at iteration time
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log(j), 100);
}
// Output: 0, 1, 2 ← Each closure captures its own j value
Why does this happen? Because there’s only one var i in memory — every closure in the loop refers to the same variable. By the time setTimeout executes, the loop is done and i equals 3. In contrast, let j creates a new variable at each iteration, so each closure captures a different value.
// ANTI-PATTERN: Using var — hoisting behavior and function scope
// can cause bugs that are very hard to track down
function contohHoisting() {
console.log(nilai); // undefined — not an error! var is hoisted to the top of the function
var nilai = 42;
console.log(nilai); // 42
}
// CORRECT: Using let — accessing before declaration is always an error
function contohTemporal() {
// console.log(nilai); // ✗ Error: Cannot access 'nilai' before initialization
let nilai = 42;
console.log(nilai); // 42
}
Type Inference vs Explicit Annotations #
TypeScript can infer a variable’s type automatically from the value given at initialization — this mechanism is called type inference. You don’t always need to write explicit type annotations.
// Explicit annotation — you tell the compiler the type
let usia: number = 25;
let nama: string = "Budi";
let aktif: boolean = true;
// Type inference — the compiler infers the type from the initial value
let usia2 = 25; // TypeScript knows this is a number
let nama2 = "Budi"; // TypeScript knows this is a string
let aktif2 = true; // TypeScript knows this is a boolean
Both produce identical type-checking behavior — TypeScript will reject a wrongly-typed assignment in both cases. So when should you write an explicit annotation?
// Situation 1: A variable declared without an initial value
// Without an annotation, TypeScript infers the type 'any' — dangerous
let hasil; // Type: any — ❌ loses type safety
let hasil2: number; // Type: number — ✓ preserved
// Situation 2: Complex or ambiguous values
// Type inference works but an annotation makes the intent clearer
const config: { host: string; port: number; ssl: boolean } = {
host: "localhost",
port: 5432,
ssl: false,
};
// Situation 3: Function return types — always explicit for public functions
function hitungLuas(panjang: number, lebar: number): number {
return panjang * lebar;
}
Practical guideline: rely on type inference for local variables that are initialized immediately. Write explicit annotations for variables declared without a value, function parameters, and function return types.
The any Type and Its Safer Alternatives
#
any is TypeScript’s emergency escape valve — it disables all type checking for that variable. An any-typed variable can accept any value and can be treated as if it has any method, without a compilation error.
let nilaiAny: any = 10;
nilaiAny = "teks"; // ✓ No error
nilaiAny = true; // ✓ No error
nilaiAny = { a: 1 }; // ✓ No error
// The danger: the compiler can't catch this bug
nilaiAny.metodeTidakAda(); // No compilation error, but will crash at runtime!
nilaiAny.properti.yang.panjang.sekali; // Also no compilation error
any basically returns you to plain JavaScript without TypeScript’s benefits. There are two far safer alternatives:
unknown — The Safe Type for Unknown Values
#
unknown is the safe replacement for any. Like any, it accepts all values. But unlike any, you cannot perform any operation on an unknown value before narrowing its type first:
// ANTI-PATTERN: any — no protection at all
function prosesAny(nilai: any): string {
return nilai.toUpperCase(); // No compilation error, but crashes if the value isn't a string
}
// CORRECT: unknown — forces narrowing before operating
function prosesUnknown(nilai: unknown): string {
if (typeof nilai === "string") {
return nilai.toUpperCase(); // ✓ Safe — TypeScript knows this is a string here
}
if (typeof nilai === "number") {
return nilai.toString(); // ✓ Safe — TypeScript knows this is a number here
}
return String(nilai); // Safe fallback
}
unknown is very useful for values coming from outside the system — API responses, user input, or data from JSON parsing:
async function ambilData(url: string): Promise<unknown> {
const response = await fetch(url);
return response.json(); // json() returns Promise<any>, we wrap it as unknown
}
// Users are forced to validate before they can use the data
const data = await ambilData("https://api.example.com/pengguna");
// data.nama // ✗ Error: Object is of type 'unknown'
if (typeof data === "object" && data !== null && "nama" in data) {
console.log((data as { nama: string }).nama); // ✓ After validation
}
Comparing any vs unknown vs Specific Types
#
| Aspect | any | unknown | Specific Types |
|---|---|---|---|
| Accepts all values | ✓ | ✓ | ✗ (only that type) |
| Operations without validation | ✓ | ✗ | ✓ |
| Type safety | ✗ None | ✓ After narrowing | ✓ Full |
| Best for | Legacy code, emergencies | External/dynamic data | Most cases |
Null and Undefined: Handling Them Correctly #
null and undefined are two of the biggest bug sources in JavaScript — and TypeScript is designed to handle them well through the strictNullChecks option. When this option is on (enabled by strict: true), null and undefined are no longer members of every type automatically.
// With strictNullChecks: true (recommended)
let nama: string = "Budi";
// nama = null; // ✗ Error: Type 'null' is not assignable to type 'string'
// nama = undefined; // ✗ Error: Type 'undefined' is not assignable to type 'string'
// To allow null/undefined, declare it explicitly
let namaNullable: string | null = null;
namaNullable = "Budi"; // ✓
namaNullable = null; // ✓
let namaOpsional: string | undefined = undefined;
namaOpsional = "Siti"; // ✓
namaOpsional = undefined; // ✓
Narrowing for Null Checks #
Before using a variable that might be null or undefined, you must narrow:
function sapaPengguna(nama: string | null): string {
// ANTI-PATTERN: Using it directly without a check — can crash
// return nama.toUpperCase(); // ✗ Error: Object is possibly 'null'
// CORRECT: Narrow first
if (nama === null) {
return "Halo, Tamu!";
}
return `Halo, ${nama.toUpperCase()}!`;
}
// Or use optional chaining and nullish coalescing
function sapaPendek(nama: string | null | undefined): string {
return `Halo, ${nama?.toUpperCase() ?? "Tamu"}!`;
}
The Non-null Assertion Operator (!)
#
TypeScript provides the ! operator to tell the compiler that you’re sure a value isn’t null or undefined — even though the type allows it:
// Use ! only if you are REALLY sure the value isn't null/undefined
function ambilElemen(id: string): HTMLElement {
const elemen = document.getElementById(id);
// elemen can be null if the ID isn't found
return elemen!; // Tells the compiler "trust me, this isn't null"
}
// ANTI-PATTERN: Misusing ! to avoid proper null handling
const elemen = document.getElementById("tombol")!;
elemen.click(); // Crashes at runtime if the element isn't in the DOM
The!operator (non-null assertion) is a danger sign equal to@ts-ignore— both locally disable compiler checks. Use it only when you have an external guarantee that the value isn’t null (for example, test setup that already ensures the DOM element exists). Don’t use it as a shortcut to avoid proper null handling.
Destructuring with Types #
Destructuring is a very common modern JavaScript feature — and TypeScript adds smart type inference on top of it.
Object Destructuring #
interface Pengguna {
id: number;
nama: string;
email: string;
usia?: number;
}
const pengguna: Pengguna = {
id: 1,
nama: "Budi Santoso",
email: "[email protected]",
};
// Destructuring — TypeScript infers the type of each variable
const { id, nama, email, usia } = pengguna;
// id: number, nama: string, email: string, usia: number | undefined
// Renaming while destructuring
const { nama: namaPengguna, email: emailPengguna } = pengguna;
console.log(namaPengguna); // "Budi Santoso"
// Default values for optional properties
const { usia: umur = 0 } = pengguna;
console.log(umur); // 0 — because usia isn't in the object
Array and Tuple Destructuring #
const koordinat: [number, number] = [106.827, -6.175]; // [longitude, latitude] Jakarta
const [longitude, latitude] = koordinat;
console.log(`Lon: ${longitude}, Lat: ${latitude}`);
// Skip elements with commas
const angka = [1, 2, 3, 4, 5];
const [pertama, , ketiga, ...sisanya] = angka;
// pertama: 1, ketiga: 3, sisanya: [4, 5]
Destructuring in Function Parameters #
// Without destructuring — verbose
function tampilkanPengguna(pengguna: Pengguna): string {
return `${pengguna.nama} <${pengguna.email}>`;
}
// With destructuring in parameters — cleaner
function tampilkanPengguna2({ nama, email }: Pengguna): string {
return `${nama} <${email}>`;
}
// With default values in parameters
function buatProfil({ nama, usia = 0 }: { nama: string; usia?: number }): string {
return `${nama} (${usia} tahun)`;
}
Scoping and Hoisting: A Visual Diagram #
Understanding the scoping differences between var, let, and const visually:
flowchart TD
A["Variable Declaration"] --> B{"Keyword?"}
B -- "var" --> C["Function Scope"]
B -- "let" --> D["Block Scope"]
B -- "const" --> E["Block Scope and No Reassign"]
C --> C1["Accessible throughout the function"]
C --> C2["Hoisted to the top of the function<br/>value: undefined"]
C --> C3["Can be redeclared<br/>in the same scope"]
D --> D1["Only in the nearest block"]
D --> D2["Temporal Dead Zone<br/>access before declaration is an Error"]
D --> D3["Cannot be redeclared<br/>in the same scope"]
E --> E1["Only in the nearest block"]
E --> E2["Temporal Dead Zone<br/>access before declaration is an Error"]
E --> E3["Binding cannot change<br/>object or array contents can change"]
style C fill:#ff6b6b,color:#fff
style C1 fill:#ffcccc
style C2 fill:#ffcccc
style C3 fill:#ffcccc
style D fill:#339af0,color:#fff
style E fill:#51cf66,color:#fffVariable Naming Conventions #
TypeScript follows the well-established JavaScript naming conventions. Naming consistency isn’t a compiler rule — but it matters for team code readability:
// camelCase — for variables and functions (most common)
let namaLengkap: string = "Budi Santoso";
let jumlahItem: number = 0;
const ambilData = () => {};
// PascalCase — for classes, interfaces, type aliases, and enums
class AuthService {}
interface DataPengguna {}
type HasilOperasi = "sukses" | "gagal";
enum StatusPesanan { Aktif, NonAktif }
// SCREAMING_SNAKE_CASE — for global constants that truly never change
const BATAS_PERCOBAAN_LOGIN: number = 3;
const URL_API_PRODUKSI: string = "https://api.muslimapps.id";
const VERSI_APLIKASI: string = "2.1.0";
// Prefixes for boolean variables — use is/has/can/should verbs
let isLoading: boolean = false;
let hasError: boolean = false;
let canEdit: boolean = true;
let shouldRefetch: boolean = false;
// ANTI-PATTERN: Non-descriptive names
let d: number; // ✗ What is this? date? duration? discount?
let tmp: string; // ✗ Temporary what?
let data2: unknown; // ✗ Why are there data and data2?
// CORRECT: Self-documenting names
let tanggalDibuat: Date;
let pesanSementara: string;
let dataResponseAPI: unknown;
Summary #
- Use
constby default — switch toletonly if the variable really needs to be reassigned; avoidvarentirely because its function scope and hoisting cause hard-to-track bugs.constdoesn’t mean immutable — it only protects the binding (reference), not the contents of an object or array; useReadonly<T>if you need protection at the property level.- Type inference is enough for local variables that are initialized immediately; write explicit annotations for variables without an initial value, function parameters, and function return types.
- Avoid
any— useunknowninstead for values whose type is truly unknown;unknownforces you to narrow before operating, which is far safer.strictNullChecksmust be on — with this option,nullandundefinedcan’t silently enter variables of other types; you’re forced to handle them explicitly.- The
!operator is a danger sign — use the non-null assertion only when you have an external guarantee the value isn’t null, not as a shortcut to avoid null handling.- Destructuring with type inference — TypeScript automatically infers the type of each destructured variable; take advantage of default values in destructuring for optional properties.
- Naming conventions: camelCase for variables and functions, PascalCase for types/interfaces/classes/enums, SCREAMING_SNAKE_CASE for global constants, and
is/has/can/shouldprefixes for boolean variables.