Skip to main content
TypeScript · lesson 4 of 21
TypeScript beginner Lesson 4 of 21

Variables in TypeScript

let and const, type annotations, inference, literal widening, as const, and the strict-mode checks — each shown with real tsc and tsx output.

By SkillByExample · Updated

Every example below was run with TypeScript 6.0.3 and Node 22. Programs run with npx tsx file.ts; compiler errors come from npx tsc --noEmit --module nodenext file.ts. TypeScript 6 turns strict on by default, so those errors appear even without a tsconfig.json — on TypeScript 5, add --strict to see the same results.

let and const

const means the binding can never be reassigned, and the compiler holds you to it.

const maxRetries: number = 3;
let currentRetry: number = 0;

currentRetry = 1;
maxRetries = 4;

console.log(currentRetry, maxRetries);
$ npx tsc --noEmit --module nodenext retries.ts
retries.ts(5,1): error TS2588: Cannot assign to 'maxRetries' because it is a constant.
$ npx tsx retries.ts
/root/ts/variables/retries.ts:5
maxRetries = 4;
^

TypeError: Assignment to constant variable.
    at <anonymous> (/root/ts/variables/retries.ts:5:1)
    at ModuleJob.run (node:internal/modules/esm/module_job:343:25)
    at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:665:26)
    at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:117:5)

Node.js v22.22.2

tsc rejects the file, but tsx strips the types without checking them, so the program still starts — and then JavaScript itself throws, because const is a runtime rule too. The type checker caught the bug before anything ran; tsx on its own did not. That split — tsx or node runs, tsc checks — is how most TypeScript projects work.

Type Inference

You rarely need to annotate a variable that has an initial value. To see what the compiler inferred, export the variables and ask tsc to write a declaration file.

export const name = "Alice";
export let city = "Paris";
export const age = 30;
export const scores = [95, 87, 92];
export const user = { name: "Alice", age: 30 };
$ npx tsc --declaration --emitDeclarationOnly --outDir types infer.ts
$ cat types/infer.d.ts
export declare const name = "Alice";
export declare let city: string;
export declare const age = 30;
export declare const scores: number[];
export declare const user: {
    name: string;
    age: number;
};

The const strings and numbers got literal types: name is exactly "Alice", not any string, because it can never change. let city was widened to string because a later line could assign "Rome". Arrays and object properties are widened for the same reason — user.name is mutable, so it’s string.

Once a type is inferred, it’s enforced like an annotation:

const user = { name: "Alice", age: 30 };

user.name = "Bob";
user.name = 42;
$ npx tsc --noEmit --module nodenext widen.ts
widen.ts(4,1): error TS2322: Type 'number' is not assignable to type 'string'.

The first assignment is fine — "Bob" is a string. The second fails because name was inferred as string from its initial value, and nothing about the object literal said it could ever hold a number.

When to Annotate

Annotate when inference can’t see the type you mean: a variable declared before it’s assigned, an empty array that should only hold certain values, or a variable that starts as null.

type Direction = "left" | "right" | "up" | "down";

let result: string;
result = ["a", "b"].join("-");

const moves: Direction[] = [];
moves.push("left");
moves.push("sideways");

let selected: string | null = null;
selected = "Alice";

console.log(result, moves, selected);
$ npx tsc --noEmit --module nodenext annotate.ts
annotate.ts(8,12): error TS2345: Argument of type '"sideways"' is not assignable to parameter of type 'Direction'.

Without the Direction[] annotation, moves would be inferred from [] and accept any string, so "sideways" would slip through. Without string | null, selected would be typed as just null, and assigning "Alice" would be the error instead. Delete the moves.push("sideways") line and the file checks clean:

type Direction = "left" | "right" | "up" | "down";

let result: string;
result = ["a", "b"].join("-");

const moves: Direction[] = [];
moves.push("left");

let selected: string | null = null;
selected = "Alice";

console.log(result, moves, selected);
$ npx tsc --noEmit --module nodenext annotate-fixed.ts
$ npx tsx annotate-fixed.ts
a-b [ 'left' ] Alice

No output from tsc means no errors. Note result never had an initial value: TypeScript tracks that it was assigned before console.log reads it. Read it before the assignment and you get “Variable ‘result’ is used before being assigned”.

Keeping Literal Types with as const

Object properties widen to string, which breaks code that expects a specific literal. as const stops the widening for the whole value and makes it readonly.

export const plain = { method: "GET", retries: 3 };
export const frozen = { method: "GET", retries: 3 } as const;

export const ROLES = ["admin", "editor", "viewer"] as const;
export type Role = (typeof ROLES)[number];
$ npx tsc --declaration --emitDeclarationOnly --outDir types as-const.ts
$ cat types/as-const.d.ts
export declare const plain: {
    method: string;
    retries: number;
};
export declare const frozen: {
    readonly method: "GET";
    readonly retries: 3;
};
export declare const ROLES: readonly ["admin", "editor", "viewer"];
export type Role = (typeof ROLES)[number];

plain.method is string; frozen.method is "GET" and readonly. ROLES became a readonly tuple of three literals, and indexing it with number gives the union Role, which stays in sync if you add a role to the array. That’s the usual replacement for an enum.

The readonly part is enforced, and the derived union works as a real type:

const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number];

function canEdit(role: Role): boolean {
  return role === "admin" || role === "editor";
}

for (const role of ROLES) {
  console.log(role, canEdit(role));
}

ROLES.push("guest");
canEdit("owner");
$ npx tsc --noEmit --module nodenext roles.ts
roles.ts(12,7): error TS2339: Property 'push' does not exist on type 'readonly ["admin", "editor", "viewer"]'.
roles.ts(13,9): error TS2345: Argument of type '"owner"' is not assignable to parameter of type '"admin" | "editor" | "viewer"'.

Remove the last two lines and it runs:

const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number];

function canEdit(role: Role): boolean {
  return role === "admin" || role === "editor";
}

for (const role of ROLES) {
  console.log(role, canEdit(role));
}
$ npx tsx roles-ok.ts
admin true
editor true
viewer false

At runtime ROLES is a plain array — as const only exists for the compiler. push doesn’t exist on a readonly tuple type, and "owner" isn’t in the union.

Strict Mode and Variables

strict switches on a family of checks. Three of them change how you declare variables: noImplicitAny, strictNullChecks, and strictPropertyInitialization.

function echo(data) {
  return data;
}

let name: string = "Alice";
name = null;

class User {
  email: string;
}

console.log(echo(1), name, new User());
$ npx tsc --noEmit --module nodenext strict.ts
strict.ts(1,15): error TS7006: Parameter 'data' implicitly has an 'any' type.
strict.ts(6,1): error TS2322: Type 'null' is not assignable to type 'string'.
strict.ts(9,3): error TS2564: Property 'email' has no initializer and is not definitely assigned in the constructor.

Each error is a runtime bug in waiting: data would silently be any, name.toUpperCase() would crash on null, and new User().email would be undefined despite being typed as string. The fixes are to type the parameter (use unknown for truly unknown input), opt into null explicitly, and initialise the property:

function echo(data: unknown) {
  return data;
}

function findUser(id: number): string | null {
  return id === 1 ? "Alice" : null;
}

class User {
  email: string;
  constructor(email: string) {
    this.email = email;
  }
}

for (const id of [1, 2]) {
  const name = findUser(id);
  if (name !== null) {
    console.log(name.toUpperCase());
  } else {
    console.log(`no user ${id}`);
  }
}
console.log(echo(1), new User("a@example.com").email);
$ npx tsc --noEmit --module nodenext strict-fixed.ts
$ npx tsx strict-fixed.ts
ALICE
no user 2
1 a@example.com

tsc prints nothing, so all three errors are gone. findUser returns string | null, and inside the if TypeScript narrows name to string, which is why toUpperCase() is allowed there. Call it without the check, as in findUser(2).toUpperCase(), and tsc reports error TS2531: Object is possibly 'null'. A value that is visibly null behaves differently: after let name: string | null = "Alice"; name = null;, control-flow analysis knows name is null, so inside if (name !== null) its type is never and toUpperCase is an error, not a fix.

Destructuring

Types flow through destructuring, including rest elements.

const user = { name: "Alice", age: 30, city: "Paris" };
const { name, ...others } = user;

const [first, ...rest] = ["a", "b", "c"];

const prefix = "user";
const key = `${prefix}_id` as const;

console.log(name.toUpperCase(), others, first, rest, key);
$ npx tsx destructure.ts
ALICE { age: 30, city: 'Paris' } a [ 'b', 'c' ] user_id

others is typed { age: number; city: string } — the compiler removes name from the rest type just as JavaScript removes it from the value. With as const, the template literal keeps the type "user_id" instead of widening to string, which is how typed event names and config keys are built.

A Worked Example

A status variable typed as a union of literals, a nullable result, and an error message narrowed from unknown:

type Status = "idle" | "loading" | "success" | "error";
type User = { id: number; name: string };

let status: Status = "idle";
let user: User | null = null;
let errorMessage: string | null = null;

async function fetchUserById(id: number): Promise<User> {
  if (id !== 1) throw new Error(`user ${id} not found`);
  return { id, name: "Alice" };
}

async function loadUser(id: number): Promise<void> {
  status = "loading";
  try {
    user = await fetchUserById(id);
    status = "success";
  } catch (err) {
    errorMessage = err instanceof Error ? err.message : "Unknown error";
    status = "error";
  }
  console.log(id, status, user?.name ?? null, errorMessage);
}

await loadUser(1);
await loadUser(2);
$ npx tsc --noEmit --module nodenext load-user.ts
$ npx tsx load-user.ts
1 success Alice null
2 error Alice user 2 not found

Top-level await works because the file is an ES module. Notice the second call still reports Alice: user was set by the first call and never cleared. The types say user is User | null; they can’t say it belongs to the current request. Grouping the three variables into one state object that’s replaced on each call fixes that.

Practice

1. In annotate-fixed.ts, remove the : Direction[] annotation and add back moves.push("sideways"). Does it still compile? What type does moves have?

Solution
const moves = [];
moves.push("left");
moves.push("sideways");
console.log(moves);
$ npx tsc --noEmit --module nodenext practice-1.ts
$ npx tsx practice-1.ts
[ 'left', 'sideways' ]

It compiles. An empty array literal starts as an “evolving” array whose type is built from what you push, so moves ends up string[] and nothing restricts it to directions. The annotation is what made "sideways" an error.

2. Give STATUS_CODES a type that makes each value a literal and derive a StatusCode union of 200 | 404 | 500 from it.

Solution
export const STATUS_CODES = { ok: 200, notFound: 404, error: 500 } as const;
export type StatusCode = (typeof STATUS_CODES)[keyof typeof STATUS_CODES];
$ npx tsc --declaration --emitDeclarationOnly --outDir types practice-2.ts
$ cat types/practice-2.d.ts
export declare const STATUS_CODES: {
    readonly ok: 200;
    readonly notFound: 404;
    readonly error: 500;
};
export type StatusCode = (typeof STATUS_CODES)[keyof typeof STATUS_CODES];

keyof typeof STATUS_CODES is "ok" | "notFound" | "error"; indexing the object type with that union gives the union of its values. Without as const each value would be number and StatusCode would just be number.

3. Read result in annotate-fixed.ts before it’s assigned. What does tsc say?

Solution
let result: string;
console.log(result);
result = ["a", "b"].join("-");
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(2,13): error TS2454: Variable 'result' is used before being assigned.

Definite-assignment analysis tracks every path to the read. A let with no initial value is undefined until assigned, which isn’t a string.

4. Fix the bug in load-user.ts where a failed call reports the previous user.

Solution
type User = { id: number; name: string };
type State =
  | { status: "success"; user: User }
  | { status: "error"; errorMessage: string };

async function fetchUserById(id: number): Promise<User> {
  if (id !== 1) throw new Error(`user ${id} not found`);
  return { id, name: "Alice" };
}

async function loadUser(id: number): Promise<State> {
  try {
    return { status: "success", user: await fetchUserById(id) };
  } catch (err) {
    const errorMessage = err instanceof Error ? err.message : "Unknown error";
    return { status: "error", errorMessage };
  }
}

console.log(await loadUser(1));
console.log(await loadUser(2));
$ npx tsc --noEmit --module nodenext practice-4.ts
$ npx tsx practice-4.ts
{ status: 'success', user: { id: 1, name: 'Alice' } }
{ status: 'error', errorMessage: 'user 2 not found' }

Each call returns a fresh state, so nothing leaks between requests. The union also means an error state can’t carry a user — the shape of the type rules out the stale-data bug, not just the value.

Frequently Asked Questions

Do I always need to write a type annotation?
No. TypeScript infers types from initial values in most cases. Add annotations when inference can't determine the right type, when a variable starts as null/undefined, or when you want to document intent explicitly.
What does strict mode change for variables?
With strict mode on, variables cannot implicitly have type any, and null/undefined are not assignable to non-nullable types. This catches real bugs and is strongly recommended. Since TypeScript 6.0, strict is on by default even without a tsconfig.json.
Should I use let or const?
Prefer const for everything that doesn't need reassignment. It signals intent and TypeScript can infer narrower types from const declarations.
How can I see the type TypeScript inferred for a variable?
Hover the variable in an editor, or export it and run tsc with --declaration --emitDeclarationOnly. The generated .d.ts file lists the exact type the compiler inferred for each exported binding, including literal types like "left" versus string.
What is the difference between as const and a type annotation?
A type annotation checks a value against a type you wrote. as const asks the compiler to infer the narrowest possible type from the value itself: literal types for every string and number, readonly properties, and readonly tuples instead of arrays.