Variables in TypeScript
Learn let and const, type annotations, type inference, and how strict mode changes variable declarations in TypeScript.
let and const
TypeScript uses the same let and const keywords as modern JavaScript. Avoiding var is important — var has function-level scoping and hoisting behavior that leads to subtle bugs. const signals that a binding won’t be reassigned, which helps both the reader and the compiler reason about your code.
const maxRetries: number = 3; // communicates intent: this value won't change
let currentRetry: number = 0; // will be incremented in a loop
currentRetry = 1; // fine
maxRetries = 4; // Error: Cannot assign to 'maxRetries' because it is a constant
Type Annotations
A type annotation is the : Type syntax written after a variable name. Annotations make the expected type explicit in source code, serving as both a constraint for the compiler and documentation for anyone reading the code later. They’re especially useful at module boundaries where intent matters.
const name: string = "Alice";
const age: number = 30;
const active: boolean = true;
const scores: number[] = [95, 87, 92];
Annotations are optional when TypeScript can infer the type from the initial value — in most simple assignments you don’t need them.
Type Inference
TypeScript automatically infers the type of a variable from its assigned value. This means you get full type safety without the verbosity of writing annotations everywhere. Inference isn’t a guess — it’s precise and reliable for the vast majority of everyday code.
const name = "Alice"; // inferred as string
const age = 30; // inferred as number
const active = true; // inferred as boolean
const scores = [1, 2]; // inferred as number[]
Inference also works through object literals — each property gets its own inferred type:
const user = {
name: "Alice",
age: 30,
};
// TypeScript infers: { name: string; age: number }
user.name = "Bob"; // fine — still a string
user.name = 42; // Error: Type 'number' is not assignable to type 'string'
When to Write Annotations Explicitly
Inference handles most cases, but there are situations where being explicit is the right call. Knowing when to annotate versus when to let TypeScript infer keeps your code clean without sacrificing safety.
The variable is declared before it is assigned:
let result: string;
// ... some logic ...
result = computeResult(); // TypeScript knows this must be a string
The inferred type is too wide for your intent:
// Inferred as string[], but you want to restrict to specific values
const directions: ("left" | "right" | "up" | "down")[] = [];
directions.push("left"); // fine
directions.push("sideways"); // Error — not in the allowed set
You want a narrow literal type:
// Without annotation: TypeScript infers string (could be reassigned to anything)
let status = "pending";
// With annotation: locked to this one value
const status: "pending" = "pending";
Initializing with null or undefined so TypeScript knows the full range:
let selectedUser: User | null = null;
// TypeScript now tracks that selectedUser can be User or null — not just null
const vs let and Type Narrowing
const declarations produce narrower types than let because TypeScript knows a const can’t be reassigned. This becomes important when building discriminated unions and working with exhaustive type checks.
const direction = "left";
// type: "left" — the literal type, because it can never change
let direction = "left";
// type: string — widened, because it could be reassigned to "right" or anything else
Using const over let wherever possible gives TypeScript more information to work with and often eliminates the need for explicit annotations.
Type Widening
TypeScript widens literal types in certain contexts — for example, inside object literals, a string property is inferred as string not as the specific string literal you wrote. Understanding widening helps you write more precise types and avoid confusing errors.
const config = {
method: "GET", // inferred as string, not "GET" — widened because objects are mutable
};
// To preserve the literal type, use "as const" on the value:
const config = {
method: "GET" as const, // inferred as "GET"
};
// Or annotate the object type explicitly:
const config: { method: "GET" | "POST" } = {
method: "GET",
};
as const
as const makes an entire value readonly and preserves all literal types throughout, including nested objects and arrays. It’s one of the most useful tools for building typed constants and enum-like structures without the overhead of the enum keyword.
const ROLES = ["admin", "editor", "viewer"] as const;
// type: readonly ["admin", "editor", "viewer"] — a fixed tuple, not string[]
// Derive a union type from the array — stays in sync automatically
type Role = (typeof ROLES)[number];
// type: "admin" | "editor" | "viewer"
This pattern is a common alternative to TypeScript enum that avoids some of enum’s quirks and produces more predictable JavaScript output.
Strict Mode and Variables
With strict: true, TypeScript enables several checks that affect how variables must be declared and used. These checks exist because the patterns they flag are genuine sources of runtime bugs.
noImplicitAny — variables must have an explicit type if inference yields any. This prevents the type system from silently falling back to “anything goes”:
// Error with noImplicitAny — 'data' implicitly has 'any' type
function process(data) {
return data;
}
// Fix — use unknown for truly unknown input, then narrow it
function process(data: unknown) {
return data;
}
strictNullChecks — null and undefined require explicit handling. This single check prevents the most common class of JavaScript runtime errors:
let name: string = "Alice";
name = null; // Error — null is not a string
let name: string | null = "Alice"; // explicitly opt in to nullability
name = null; // fine
// You must check before using — TypeScript won't let you forget
if (name !== null) {
console.log(name.toUpperCase()); // safe
}
strictPropertyInitialization — class properties must be initialized in the constructor. This prevents accessing undefined on a class instance:
class User {
name: string; // Error: Property 'name' has no initializer
constructor(name: string) {
this.name = name; // required — TypeScript enforces the initialization
}
}
Destructuring with Types
TypeScript infers types through destructuring automatically — you usually don’t need to annotate destructured variables. The types flow through from the original object or array.
const user = { name: "Alice", age: 30 };
const { name, age } = user;
// name: string, age: number — inferred automatically from the object type
When you need to annotate destructured variables explicitly:
const { name, age }: { name: string; age: number } = user;
Array destructuring with rest elements:
const [first, ...rest]: [string, ...string[]] = ["a", "b", "c"];
// first: string, rest: string[]
Template Literal Types with Variables
TypeScript tracks string template types when all parts are known at compile time. This is a more niche feature but becomes useful for building typed configuration keys or event name systems.
const prefix = "user" as const;
const key = `${prefix}_id`;
// type: "user_id" — TypeScript constructs the literal type
Practical Example
This example shows how variable typing, unions, and null handling work together in a real-world data-fetching scenario. Each variable’s type reflects exactly what it can hold at any point in the lifecycle.
type Status = "idle" | "loading" | "success" | "error";
// Each variable is typed to reflect only the valid values it can hold
let fetchStatus: Status = "idle";
let userData: User | null = null;
let errorMessage: string | null = null;
async function loadUser(id: number): Promise<void> {
fetchStatus = "loading";
errorMessage = null;
try {
userData = await fetchUserById(id);
fetchStatus = "success";
} catch (err) {
// TypeScript ensures we produce a string, not an arbitrary error object
errorMessage = err instanceof Error ? err.message : "Unknown error";
fetchStatus = "error";
}
}
TypeScript enforces that fetchStatus can only be one of the four valid strings, userData must be checked for null before access, and errorMessage is always a string or null — never an arbitrary object.