Ch. 2 · TypeScript

TS2532: Object Is Possibly 'undefined' (and TS18048) in TypeScript

Fix TS2532 'Object is possibly undefined' and TS18048 by finding where undefined enters the type, then narrowing or failing early.

~8 min readbeginnerupdated Oct 4, 2026

You ran tsc or opened the file in your editor and got one of these:

src/profile.ts(7,10): error TS18048: 'user.address' is possibly 'undefined'.
src/profile.ts(11,10): error TS2532: Object is possibly 'undefined'.
Text

Both mean the same thing: you are reading a property, calling a method or indexing into a value whose type includes undefined, and if that value really is undefined at runtime the line will crash with TypeError: Cannot read properties of undefined. TypeScript prints TS18048 when the expression has a name it can show you (a variable or a property path like user.address) and TS2532 when it does not (a function call result such as findUser(id).name, or an element access like scores[0].toFixed()). The null versions are TS18047 ('el' is possibly 'null'), TS2531 (Object is possibly 'null') and TS18049 (possibly 'null' or 'undefined').

Quick fix checklist

  • Hover the expression the column number points at and look for | undefined in its type.
  • Ask whether undefined is a legitimate state (no address yet) or a bug (a config key that must exist).
  • Legitimate state: narrow with if (!x) return ..., or use x?.prop ?? fallback.
  • Bug: throw a descriptive error at the point where the value is fetched, so the rest of the code receives a non-optional type.
  • Inside callbacks, copy a narrowed property into a const before the callback.
  • Use the non-null assertion x! only when you can state why it cannot be undefined, and write that reason in a comment.
  • Do not switch off strictNullChecks to make the error go away.

Before you start

These errors only exist with strictNullChecks, which "strict": true turns on. TypeScript 6.0 made strict the default, so upgrading an old project with no strict setting can make hundreds of them appear at once. Check which compiler you are running with npx tsc -v, and make sure your editor uses the workspace version rather than its bundled one, otherwise the editor and the build can disagree.

The examples were compiled with TypeScript 7.0 and 5.9 using strict: true; both print identical messages for every case below.

Why it happens

With strictNullChecks on, undefined and null are no longer silently part of every type. A string is only a string. The only way undefined gets into a type is when something puts it there:

  • an optional property or parameter: address?: Address means Address | undefined
  • library signatures that can fail: Array.prototype.find, Map.get, document.getElementById (which returns HTMLElement | null)
  • index access when noUncheckedIndexedAccess is on: scores[0] becomes number | undefined
  • your own functions that declare T | undefined as their return type

TypeScript then refuses any operation that would fail on undefined: property access, method calls, element access and calling the value as a function.

The way you remove undefined is narrowing. The compiler follows your control flow: after if (!user.address) return; it knows that on every path that reaches the next line, user.address is an Address. Narrowing is attached to a specific reference (a variable or a property path) at a specific point in the code. Anything that might invalidate it, such as an assignment or code that runs later, makes the compiler fall back to the declared type. That last rule explains most “but I already checked it” complaints.

Step-by-step walkthrough

Step 1: Reproduce and read the location

Here is a file that triggers both codes:

interface Address { city: string }
interface User { name: string; address?: Address }

declare function findUser(id: string): User | undefined;

export function label(user: User) {
  return user.address.city.toUpperCase();
}

export function byId(id: string) {
  return findUser(id).name;
}
TypeScript
npx tsc --noEmit
Terminal
src/profile.ts(7,10): error TS18048: 'user.address' is possibly 'undefined'.
src/profile.ts(11,10): error TS2532: Object is possibly 'undefined'.
Text

The column points at the start of the expression that may be undefined, not at the property you were trying to reach. On line 7 the problem is user.address, not city.

Step 2: Find where undefined enters

Hover the flagged expression or jump to its declaration. You will find an optional ?, a | undefined, or one of the library calls listed above. This is the decision point: a user without an address is normal data, while a missing findUser result for an id from your own database is probably a bug. The fix depends on which one you have.

Step 3: Narrow when absence is a normal state

export function label(user: User) {
  if (!user.address) return "No address";
  return user.address.city.toUpperCase();
}

export function shortLabel(user: User) {
  return user.address?.city.toUpperCase() ?? "UNKNOWN";
}
TypeScript

The early return keeps the happy path flat. Optional chaining (?.) stops evaluating the whole chain as soon as user.address is undefined, and ?? supplies a value only for null or undefined (unlike ||, which also replaces "" and 0).

Step 4: Fail early when absence is a bug

If the value must exist, say so once, where it is fetched, and give the rest of the code a clean type:

export function requireUser(id: string): User {
  const user = findUser(id);
  if (!user) throw new Error(`User ${id} not found`);
  return user;
}

export function byId(id: string) {
  return requireUser(id).name;
}
TypeScript

The message names the id that failed, instead of a generic TypeError three functions later.

Step 5: Handle callbacks and indexes

Narrowing a property path does not survive into a callback, because the compiler cannot know when the callback runs or whether user.address was reassigned in between:

export function cities(user: User, items: string[]) {
  if (user.address) {
    items.forEach(() => console.log(user.address.city));
  }
}
TypeScript
src/profile.ts(16,37): error TS18048: 'user.address' is possibly 'undefined'.
Text

Copy the value into a const. A const can never be reassigned, so its narrowing is kept inside the closure:

export function cities(user: User, items: string[]) {
  const address = user.address;
  if (address) {
    items.forEach(() => console.log(address.city));
  }
}
TypeScript

Since TypeScript 5.4 this also works for a let variable or parameter, provided there is no assignment to it after the closure is created.

For arrays and records, consider turning on noUncheckedIndexedAccess. It is not part of strict, and it makes scores[0] and byName["ada"] produce TS2532 because the index might be out of range. for...of loops and array methods such as map are unaffected, so most code needs few changes.

Worked scenario

A checkout service computes the price for a plan the customer selected:

interface Plan { id: string; monthly: number }
const plans: Plan[] = [
  { id: "starter", monthly: 9 },
  { id: "team", monthly: 29 },
];

export function quote(planId: string, seats: number) {
  const plan = plans.find((p) => p.id === planId);
  return plan.monthly * seats;
}
TypeScript
src/billing.ts(9,10): error TS18048: 'plan' is possibly 'undefined'.
Text

Diagnosis: find returns Plan | undefined because no element might match. planId comes from a request body, so an unknown id is a client error, not an impossible state. A developer under pressure writes plan!.monthly. It compiles, then throws a TypeError at runtime for every unknown id and turns what should be a 400 response into a 500.

The fix makes the failure explicit and typed:

export class UnknownPlanError extends Error {
  readonly planId: string;
  constructor(planId: string) {
    super(`Unknown plan: ${planId}`);
    this.planId = planId;
  }
}

export function quote(planId: string, seats: number): number {
  const plan = plans.find((p) => p.id === planId);
  if (plan === undefined) throw new UnknownPlanError(planId);
  return plan.monthly * seats;
}
TypeScript

The HTTP layer catches UnknownPlanError and answers 400. After the guard, plan is a Plan, and the compiler proves it for you.

Common mistake

The tempting fix is the non-null assertion:

const plan = plans.find((p) => p.id === planId)!;
TypeScript

! removes null and undefined from the type and emits nothing at runtime. It is a promise to the compiler, not a check. Use it only when you hold information the compiler cannot see, for example an element your own HTML template always renders, and even then a guard that throws a clear message costs one line.

Other wrong turns:

  • as Plan is the same promise with more typing.
  • Setting "strictNullChecks": false hides every one of these errors, including the ones that are real bugs.
  • Writing ?. everywhere silences the error but spreads | undefined into every caller, so the problem resurfaces as TS2322 elsewhere (see TS2322 type is not assignable).
  • Using || 0 for a count that can legitimately be 0 works by accident; prefer ?? 0.

Verify the behavior

First prove the types: npx tsc --noEmit should print nothing and echo $? should print 0. Then prove the runtime branch you added. Node 22.18 and later run .ts files directly by stripping types, so a test needs no extra tooling:

import { test } from "node:test";
import assert from "node:assert/strict";
import { quote, UnknownPlanError } from "./billing.ts";

test("prices a known plan", () => {
  assert.equal(quote("team", 3), 87);
});

test("rejects an unknown plan with a typed error", () => {
  assert.throws(() => quote("enterprise", 3), UnknownPlanError);
});
TypeScript
node --test src/billing.test.ts
Terminal

Both tests should pass. Two details make this work: importing ./billing.ts with its extension needs "allowImportingTsExtensions": true (with noEmit) for tsc to accept it, and Node’s type stripping rejects syntax that generates code, such as enums and constructor parameter properties. That is why UnknownPlanError declares its planId field explicitly.

Interview exercise

The following code fails to compile even though it checks user.address first. Why, and what are two ways to fix it?

function notify(user: User, recipients: string[]) {
  if (user.address) {
    recipients.forEach((r) => send(r, user.address.city));
  }
}
TypeScript

Answer and reasoning

Narrowing is tied to a reference at a point in the control flow. The arrow function is a separate piece of code that may run later, and in between something could set user.address = undefined (the object is mutable and other code may hold a reference to it). TypeScript therefore resets narrowings of property paths inside function expressions and reports TS18048 on user.address.

Fix one: copy it into a const address = user.address and narrow that; a const binding cannot change, so the narrowing is safe to keep. Fix two: narrow inside the callback, or restructure with an early return that hands a non-optional Address to a helper. A strong answer also notes that await does not reset narrowing even though other code can run during it: the compiler is pragmatic rather than perfectly sound.

Continue learning

More in TypeScript

esc