Skip to content

Fix: TS2532 Object is possibly 'undefined' / Object is possibly 'null'

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix TypeScript errors TS2532 'Object is possibly undefined', TS18048 'Object is possibly undefined', and 'Object is possibly null'. Covers optional chaining, nullish coalescing, type narrowing, non-null assertion, type guards, strictNullChecks, Array.find, Map.get, React useRef, and more.

The Error

You write TypeScript code that compiles fine in your head, but the compiler disagrees:

error TS2532: Object is possibly 'undefined'.
error TS2532: Object is possibly 'null'.

Or in TypeScript 5.0+, the more specific variant:

error TS18048: 'user' is possibly 'undefined'.
error TS18047: 'user' is possibly 'null'.

All of these mean the same thing: you’re accessing a property or calling a method on a value that TypeScript thinks could be undefined or null. TypeScript won’t let you do that because it would crash at runtime.

Why This Happens

TypeScript’s type system tracks whether a value can be null or undefined. When strictNullChecks is enabled (it’s on by default in most modern configs, and it’s part of strict: true), TypeScript treats null and undefined as distinct types that aren’t assignable to other types.

Here’s the simplest reproduction:

function getUser(id: string): User | undefined {
  return users.find(u => u.id === id);
}

const user = getUser('123');
console.log(user.name);
//          ~~~~
// error TS2532: Object is possibly 'undefined'.

TypeScript sees that getUser can return undefined. You’re accessing .name without first proving that user isn’t undefined. That’s a potential runtime crash, so TypeScript stops you.

This happens in many common scenarios:

  • Array.find() returns T | undefined
  • Map.get() returns V | undefined
  • Optional properties (name?: string) are string | undefined
  • document.getElementById() returns HTMLElement | null
  • React refs (useRef<T>(null)) start as null
  • Object index signatures (Record<string, T>) return T | undefined

TypeScript is protecting you. Every one of these can legitimately be undefined or null at runtime. If you ignore these warnings, you’ll end up with TypeError: Cannot read properties of undefined at runtime. The fix is to handle that possibility explicitly.

How Other Tools Handle This

The “possibly undefined” check is one of TypeScript’s most opinionated decisions. Knowing how the alternatives behave makes it easier to pick the right escape hatch when you hit this error.

TypeScript with strictNullChecks vs JSDoc-only checking. If you use // @ts-check in a plain JavaScript file or run tsc against .js files via checkJs, TypeScript still enforces strictNullChecks when it is on, but the types come from JSDoc comments. The error message is identical, but the fix surface is narrower — you cannot use the ! operator inline, so you fall back on if narrowing or /** @type {User} */ casts. The runtime behavior of your code does not change either way.

Flow. Flow has the same concept and emits property access on possibly-null value or incompatible-use errors. The pragmatic difference is that Flow narrows array.find() and Map.get() after a .has() check in some configurations, where TypeScript does not. If you are migrating Flow code to TypeScript and suddenly seeing TS2532 in spots that compiled cleanly under Flow, this is usually why. The fix is the same: explicit narrowing or !.

Hegel. Hegel was an alternative JS type checker that pushed soundness further than TypeScript — it refused implicit any and enforced exhaustive nullability checks even in places TS lets through. The project is effectively unmaintained, so do not adopt it for new code, but if you inherit a Hegel project the diagnostics map closely to TS2532 and the same narrowing patterns apply.

Kotlin and Swift nullable types. Kotlin’s User? and Swift’s User? are the closest analogs to User | undefined. Both languages enforce nullability at the type system level, but they ship richer ergonomics: Kotlin’s ?.let { }, Swift’s if let, and Swift’s guard let all unwrap in one line. The TypeScript equivalent is the if (user) { } narrowing in Fix 1 plus optional chaining in Fix 2 — same idea, more verbose syntax. Crucially, Kotlin’s !! and Swift’s force-unwrap ! are runtime checks that throw, while TypeScript’s ! is purely a compile-time hint that erases at build. Misuse looks the same in code but fails differently in production.

assert vs ! non-null vs narrowing. These three look interchangeable but behave very differently:

  • Narrowing (if (user) { ... }) is the only one that gives you both compile-time and runtime safety. The check exists in the emitted JS.
  • Node’s assert() plus an assertion function (function assertDefined<T>(v: T | null | undefined): asserts v is T) gives you a runtime throw and TypeScript narrowing afterward. Best for hot paths where you want the crash to be loud and traced.
  • The ! operator erases at compile. There is no runtime check at all. If you are wrong, the next property access throws a generic TypeError: Cannot read properties of undefined instead of a meaningful assertion message.

When you reach for !, ask whether a runtime assertion would serve you better. The cost is one function call; the benefit is a real stack trace pointing at the broken assumption.

Fix

1. Type Narrowing with if Checks

The most straightforward fix. Check for null or undefined before using the value. TypeScript is smart enough to narrow the type inside the if block.

const user = getUser('123');

if (user) {
  console.log(user.name); // TypeScript knows user is User here
}

This works because TypeScript performs control flow analysis. After the if (user) check, it knows user can’t be undefined or null inside the block.

Different narrowing patterns:

// Truthiness check (excludes null, undefined, 0, '', false)
if (user) { ... }

// Strict equality (most precise)
if (user !== undefined) { ... }
if (user !== null) { ... }
if (user != null) { ... }  // excludes both null AND undefined

// typeof check (useful for union types)
if (typeof value === 'string') { ... }

Use != null (loose equality) when you want to exclude both null and undefined in one check.

2. Optional Chaining (?.)

When you want to access a nested property but any part of the chain might be undefined or null:

const user = getUser('123');
console.log(user?.name);           // string | undefined (no error)
console.log(user?.address?.city);  // safe nested access

Optional chaining short-circuits. If user is undefined, the entire expression returns undefined instead of throwing.

It works with method calls and bracket notation too:

user?.getProfile?.()        // safe method call
user?.['first-name']        // safe bracket access
users?.[0]?.name            // safe array index access

3. Nullish Coalescing (??)

Combine with optional chaining to provide a fallback value:

const name = user?.name ?? 'Anonymous';
const city = user?.address?.city ?? 'Unknown';

Use ?? instead of ||. The || operator treats '', 0, and false as falsy and replaces them with the fallback. The ?? operator only triggers on null and undefined:

const count = user?.postCount ?? 0;   // correct: 0 stays as 0
const count = user?.postCount || 0;   // bug: if postCount is 0, it becomes 0 anyway... but '' || 'default' would be 'default'

4. Non-Null Assertion Operator (!)

If you know a value isn’t null or undefined but TypeScript can’t prove it, use the ! operator:

const user = getUser('123');
console.log(user!.name); // you promise TypeScript this is not null/undefined

Use this sparingly. The ! operator tells TypeScript to shut up. If you’re wrong, you get a runtime crash — the exact thing TypeScript was trying to prevent. It has valid use cases (see below), but reach for type narrowing or optional chaining first.

Valid use case — when you’ve already checked but TypeScript lost track:

const users = new Map<string, User>();
if (users.has(id)) {
  // TypeScript doesn't narrow Map.get() after .has()
  const user = users.get(id)!; // safe: you just checked .has()
}

5. Array.find() Returning undefined

Array.find() returns T | undefined because the element might not exist. This is one of the most common triggers.

const users: User[] = [{ id: '1', name: 'Alice' }];
const user = users.find(u => u.id === '1');
console.log(user.name);
//          ~~~~  Object is possibly 'undefined'.

Fix — narrow with an if check:

const user = users.find(u => u.id === '1');
if (!user) {
  throw new Error('User not found');
}
console.log(user.name); // TypeScript knows user is User after the throw

Fix — when you genuinely know it exists:

// Use non-null assertion only when you're certain
const user = users.find(u => u.id === '1')!;

Fix — use a type-safe helper that throws:

function findOrThrow<T>(arr: T[], predicate: (item: T) => boolean): T {
  const result = arr.find(predicate);
  if (!result) throw new Error('Element not found');
  return result;
}

const user = findOrThrow(users, u => u.id === '1'); // type: User

6. Map.get() Returning undefined

Map.get() returns V | undefined because the key might not exist. TypeScript doesn’t narrow this even after a .has() check.

const cache = new Map<string, User>();

const user = cache.get('alice');
console.log(user.name);
//          ~~~~  Object is possibly 'undefined'.

Fix — use the value from the narrowing check:

const user = cache.get('alice');
if (user) {
  console.log(user.name); // narrowed to User
}

Fix — use non-null assertion after .has():

if (cache.has('alice')) {
  const user = cache.get('alice')!; // safe after .has()
  console.log(user.name);
}

7. Optional Properties

Properties marked with ? are T | undefined:

interface Config {
  database?: {
    host: string;
    port: number;
  };
}

function connect(config: Config) {
  console.log(config.database.host);
  //          ~~~~~~~~~~~~~~~  Object is possibly 'undefined'.
}

Fix — narrow or use optional chaining:

function connect(config: Config) {
  if (!config.database) {
    throw new Error('Database config is required');
  }
  console.log(config.database.host); // narrowed
}

// Or with optional chaining + fallback:
const host = config.database?.host ?? 'localhost';

Pro Tip: Prefer type narrowing (if checks) and optional chaining (?.) over the non-null assertion operator (!). The ! operator tells TypeScript “trust me,” but if you’re wrong, you get exactly the runtime crash TypeScript was trying to prevent. Save ! for cases where you’ve verified safety through logic TypeScript can’t follow, like after Map.has().

8. React Refs (useRef)

React refs initialized with null have type T | null. You hit this error when you access .current properties:

const inputRef = useRef<HTMLInputElement>(null);

function focusInput() {
  inputRef.current.focus();
  //       ~~~~~~~  Object is possibly 'null'.
}

Fix — null check before access:

function focusInput() {
  if (inputRef.current) {
    inputRef.current.focus();
  }
  // Or:
  inputRef.current?.focus();
}

If you’re using refs inside conditional hooks, make sure you’re not violating the Rules of Hooks. For refs that are always set after mount (attached to a DOM element that’s always rendered), some codebases use the non-null assertion:

const inputRef = useRef<HTMLInputElement>(null!);

This tells TypeScript the ref will never actually be null when you access it. Only do this if the ref is guaranteed to be attached — if the element conditionally renders, you’ll crash.

9. Definite Assignment Assertion

When you declare a variable and assign it later (in a way TypeScript can’t track), use ! in the declaration:

let connection: DatabaseConnection;

// Assigned in init() which is always called before use
async function init() {
  connection = await createConnection();
}

function query(sql: string) {
  return connection.execute(sql);
  //     ~~~~~~~~~~  Variable 'connection' is used before being assigned.
}

Fix — definite assignment assertion:

let connection!: DatabaseConnection; // the ! tells TypeScript: trust me, it'll be assigned

Use this in class properties too:

class App {
  private db!: Database; // assigned in init(), not in the constructor

  async init() {
    this.db = await connectToDatabase();
  }
}

10. Custom Type Guards

When you have complex logic to determine whether a value is defined, write a type guard:

function isDefined<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}

const users: (User | undefined)[] = [getUser('1'), getUser('2')];

// Filter out undefined values with proper typing
const definedUsers: User[] = users.filter(isDefined);

Without the type guard, users.filter(u => u !== undefined) still types the result as (User | undefined)[]. The type guard tells TypeScript what the filter actually does.

This is also powerful for discriminated unions:

interface SuccessResponse {
  status: 'success';
  data: User;
}

interface ErrorResponse {
  status: 'error';
  message: string;
}

type ApiResponse = SuccessResponse | ErrorResponse;

function isSuccess(res: ApiResponse): res is SuccessResponse {
  return res.status === 'success';
}

function handleResponse(res: ApiResponse) {
  if (isSuccess(res)) {
    console.log(res.data.name); // TypeScript knows res is SuccessResponse
  }
}

11. Exhaustive Checks with never

When you use switch statements or conditional chains on discriminated unions, TypeScript narrows types at each branch. Use never to ensure you handle every case:

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number };

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.side ** 2;
    default:
      const _exhaustive: never = shape; // compile error if a case is missing
      return _exhaustive;
  }
}

If someone adds a 'triangle' variant to Shape and forgets to add a case here, TypeScript will catch it at compile time.

strictNullChecks in tsconfig.json

If you’re not seeing these errors at all, strictNullChecks might be off. Check your tsconfig.json:

{
  "compilerOptions": {
    "strict": true
  }
}

strict: true enables strictNullChecks along with other strict flags. You can also enable it individually:

{
  "compilerOptions": {
    "strictNullChecks": true
  }
}

Keep it on. Turning off strictNullChecks silences these errors but doesn’t fix the underlying bugs. Your code can still crash at runtime — TypeScript just stops warning you about it.

If you’re enabling it on a large existing codebase and get hundreds of errors, adopt it incrementally. Fix one file at a time. Use // @ts-expect-error to temporarily suppress errors you’ll fix later, rather than turning the whole flag off.

Still Not Working?

TypeScript Doesn’t Narrow After Array.includes() or Set.has()

TypeScript doesn’t narrow types after .includes() or .has() checks the way you might expect:

const validStatuses = ['active', 'pending'] as const;
type Status = typeof validStatuses[number];

function process(status: string) {
  if (validStatuses.includes(status as Status)) {
    // status is still `string` here, not narrowed
  }
}

Use a type guard instead:

function isValidStatus(status: string): status is Status {
  return (validStatuses as readonly string[]).includes(status);
}

Narrowing Doesn’t Persist Across Callbacks

TypeScript resets narrowing inside callbacks because the callback might execute later when the variable could have changed:

const user = getUser('123');
if (user) {
  setTimeout(() => {
    console.log(user.name); // this actually works (closure captures the narrowed value)
  }, 1000);

  someArray.forEach(() => {
    // works here too, because `user` is const and captured by closure
  });
}

But this fails when the variable can be reassigned:

let user = getUser('123');
if (user) {
  setTimeout(() => {
    console.log(user.name);
    //          ~~~~  Object is possibly 'undefined'.
  }, 1000);
}

Fix: use const instead of let, or capture the narrowed value in a const:

let user = getUser('123');
if (user) {
  const currentUser = user; // capture narrowed value
  setTimeout(() => {
    console.log(currentUser.name); // works
  }, 1000);
}

document.getElementById Always Returns null Type

DOM methods like getElementById, querySelector, and closest return nullable types. Even if you’re sure the element exists:

const el = document.getElementById('app');
el.classList.add('loaded');
// ~~  Object is possibly 'null'.

Fix with narrowing and a helpful error message:

const el = document.getElementById('app');
if (!el) {
  throw new Error('Could not find #app element. Check your HTML.');
}
el.classList.add('loaded'); // narrowed to HTMLElement

Or if you need a specific element type:

const input = document.getElementById('email') as HTMLInputElement | null;
if (input) {
  input.value = '[email protected]'; // narrowed to HTMLInputElement
}

Conflicting Type Assertions and the object is possibly undefined Error in Generics

When you use generics with constraints, TypeScript sometimes can’t tell that a property access is safe:

function getFirst<T extends { items?: string[] }>(obj: T) {
  return obj.items[0];
  //        ~~~~~  Object is possibly 'undefined'.
}

The optional items property means it could be undefined. Narrow it:

function getFirst<T extends { items?: string[] }>(obj: T) {
  if (!obj.items || obj.items.length === 0) {
    return undefined;
  }
  return obj.items[0];
}

Assertion Functions Don’t Narrow Across Async Boundaries

TypeScript’s assertion functions (asserts x is NonNullable<T>) narrow synchronously, but the narrowing is lost the moment you cross an await:

function assertDefined<T>(v: T | null | undefined): asserts v is T {
  if (v == null) throw new Error('value is null');
}

async function load(id: string) {
  const user = await getUser(id);
  assertDefined(user);
  await wait(10);
  console.log(user.name); // still narrowed — value didn't change
}

That actually works because user is const. But if you reassign across await or pull from a mutable closure, the narrowing erodes. Capture the asserted value into a fresh const immediately after the assertion if you plan to use it later.

tsconfig exactOptionalPropertyTypes Changes the Story

If you enable exactOptionalPropertyTypes, { name?: string } no longer accepts { name: undefined }. That sounds like a tightening but it can change which assignments compile, which in turn changes whether TypeScript sees a value as T or T | undefined. If you toggled this flag and started seeing TS2532 on code that compiled before, this is a likely cause. Either keep the flag on and add explicit undefined handling, or turn it off and document the inconsistency.

noUncheckedIndexedAccess Adds undefined Everywhere

This flag (off by default) makes every indexed access through arrays and objects return T | undefined:

const arr = [1, 2, 3];
const x = arr[0]; // number | undefined with the flag on

Turning this flag on retroactively can produce hundreds of TS2532 errors in a previously clean codebase. The errors are genuine — arr[999] really is undefined — but you almost always know the access is safe. Use type narrowing, optional chaining, or a small helper like at(arr, i, fallback) to make intent explicit instead of disabling the flag.

Consider Using the satisfies Operator

TypeScript 4.9+ introduced satisfies, which can help preserve narrower types while still validating against a wider type:

type Route = {
  path: string;
  handler?: () => void;
};

// With `satisfies`, TypeScript knows exactly which routes have handlers
const routes = {
  home: { path: '/', handler: () => {} },
  about: { path: '/about' },
} satisfies Record<string, Route>;

routes.home.handler(); // no error — TypeScript knows handler exists on home
routes.about.handler();
//           ~~~~~~~  Property 'handler' does not exist

Related: Fix: TypeError: Cannot read properties of undefined | Fix: TS2307 Cannot find module or its corresponding type declarations | Fix: ESLint Parsing error: Unexpected token

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles