Question

How to Handle TS2533: Object Is Possibly 'null' or 'undefined' in TypeScript

Question

I have the following TypeScript type:

type tSelectProtected = {
  handleSelector?: string,
  data?: tSelectDataItem[],

  wrapperEle?: HTMLElement,
  inputEle?: HTMLElement,
  listEle?: HTMLElement,
  resultEle?: HTMLElement,

  maxVisibleListItems?: number
}

I also declare a module-level variable like this:

let $protected: tSelectProtected = {};

In one function, I assign a value:

$protected.listEle = document.createElement('div');

Later, in another function, I use it like this:

$protected.listEle.classList.add('visible');

TypeScript reports this error:

error TS2533: Object is possibly 'null' or 'undefined'

I understand that I can add an explicit check such as:

if ($protected.listEle) {
  $protected.listEle.classList.add('visible');
}

but that becomes inconvenient in larger code paths.

How should this situation be handled properly without disabling TypeScript compiler checks?

Short Answer

By the end of this page, you will understand why TypeScript warns about optional properties, why assignment in one function does not automatically guarantee safety in another, and how to fix TS2533 using proper narrowing, non-null assertions, type assertions, better state design, and helper patterns.

Concept

TypeScript shows errors like TS2533: Object is possibly 'null' or 'undefined' when a value might not exist at runtime.

In your example, the property is declared as optional:

listEle?: HTMLElement

An optional property means:

HTMLElement | undefined

So when you write:

$protected.listEle.classList.add('visible');

TypeScript sees a risk: listEle might still be undefined at that moment.

Why TypeScript does this

TypeScript analyzes code based on what is guaranteed by the type system, not what you know happened earlier in another function.

This matters because:

  • another function may run before initialization
  • another part of the code may reset the property
  • control flow across function boundaries is not always guaranteed
  • module-level mutable state is harder for TypeScript to prove safe

The real issue

The core issue is not the DOM element itself. The issue is that your type says the property may be missing.

If a value is truly required before use, you usually have these options:

  • check for it before using it
  • assert that it exists
  • redesign the type so it is no longer optional at the point of use
  • move initialization closer to usage

Why this matters in real programming

This kind of error appears everywhere:

  • DOM lookups like document.querySelector(...)
  • API data that may be missing fields
  • configuration objects with optional settings
  • cached values that are initialized later
  • function parameters that may be omitted

Learning to handle nullable and optional values correctly makes your code safer and easier to maintain.

Mental Model

Think of an optional property as a labeled box that may or may not contain an item.

If the label says:

listEle?: HTMLElement

then TypeScript treats it like a box that could be empty.

Before you use the item inside, you must do one of three things:

  • look inside the box first with an if check
  • declare that you are sure it is there with !
  • change your design so the box is always filled before anyone can access it

TypeScript is like a cautious coworker saying:

"I see you want to use listEle, but your type says it might not exist. Prove that it exists, or change the design so that it always does."

Take Quiz

Syntax and Examples

1. Narrow with an if check

This is the safest approach.

if ($protected.listEle) {
  $protected.listEle.classList.add('visible');
}

Inside the if block, TypeScript knows listEle is an HTMLElement.

2. Use optional chaining

If you want to do nothing when the value is missing:

$protected.listEle?.classList.add('visible');

This avoids the error and safely skips the call if listEle is undefined.

Use this only when "do nothing if missing" is acceptable.

3. Use the non-null assertion operator !

If you know the value has definitely been assigned before this line:

$protected.listEle!.classList.add('visible');

This tells TypeScript:

"Trust me, this is not null or undefined."

Be careful: if you are wrong, runtime errors can still happen.

4. Use a local variable after narrowing

This is often cleaner in longer code:

const listEle = $protected.listEle;
if (!listEle) return;

listEle.classList.add('visible');
listEle.setAttribute('data-state', 'open');

This pattern is common and readable.

5. Improve the type design

If listEle is required after initialization, split your types.

type SelectProtectedDraft = {
  listEle?: HTMLElement;
};

type SelectProtectedReady = {
  listEle: HTMLElement;
};

Then only use SelectProtectedReady in code paths where initialization is complete.

Example with a helper function

function getListEle(): HTMLElement {
  if (!$protected.listEle) {
    throw new Error('listEle has not been initialized');
  }
  return $protected.listEle;
}

getListEle().classList.add('visible');

This is useful when many places need the same guarantee.

Step by Step Execution

Consider this example:

let state: { listEle?: HTMLElement } = {};

function init() {
  state.listEle = document.createElement('div');
}

function show() {
  state.listEle?.classList.add('visible');
}

What happens step by step

1. Initial state

let state: { listEle?: HTMLElement } = {};

At this point:

  • state exists
  • state.listEle is undefined

2. Initialization function runs

state.listEle = document.createElement('div');

Now at runtime, listEle points to a real div element.

3. Another function uses it

state.listEle?.classList.add('visible');

TypeScript allows this because optional chaining handles the possibility that listEle might still be undefined.

4. If you used direct access instead

state.listEle.classList.add('visible');

TypeScript rejects it because the type still says:

HTMLElement | undefined

Even if you assigned it earlier, TypeScript does not treat a mutable optional property on a shared object as permanently guaranteed across separate functions.

Trace with a guard clause

function showSafe() {
  const listEle = state.listEle;
  if (!listEle) return;

  listEle.classList.add('visible');
}

Execution flow:

  1. Read state.listEle into listEle
  2. If it is missing, exit early
  3. Otherwise, TypeScript narrows listEle to HTMLElement
  4. Safe DOM methods can now be called

Real World Use Cases

DOM elements created later

A component may create DOM nodes during setup and use them later during events.

panelEl?.classList.add('open');

Elements returned from selectors

DOM queries often return null if no match is found.

const button = document.querySelector('button');
button?.addEventListener('click', onClick);

API response fields

A server response may omit optional fields.

type User = {
  name: string;
  avatarUrl?: string;
};

if (user.avatarUrl) {
  console.log(user.avatarUrl);
}

Lazy initialization

Some values are created only when needed.

let cache: Map<string, string> | undefined;

function getCache() {
  if (!cache) cache = new Map();
  return cache;
}

Optional configuration

Applications often accept partial options objects.

type Config = {
  timeout?: number;
};

const timeout = config.timeout ?? 5000;

These are all examples of the same idea: the value may be absent, so the code must either handle that absence or remove it from the type.

Take Quiz

Real Codebase Usage

In real projects, developers usually avoid scattering repeated null checks everywhere by using a few common patterns.

Guard clauses

Exit early if a required value is missing.

function showList() {
  const listEle = $protected.listEle;
  if (!listEle) return;

  listEle.classList.add('visible');
}

This keeps the main logic clean.

Assertion helper functions

Centralize the check in one place.

function requireListEle(): HTMLElement {
  if (!$protected.listEle) {
    throw new Error('listEle is not initialized');
  }
  return $protected.listEle;
}

requireListEle().classList.add('visible');

This is especially useful when missing state is a programmer error.

Separate initialization and ready-state types

Many codebases model state transitions explicitly.

type SelectState = {
  listEle?: HTMLElement;
};

function initialize(state: SelectState): asserts state is { listEle: HTMLElement } {
  state.listEle = document.createElement('div');
}

Or they return a fully built object:

type ReadySelectState = {
  listEle: HTMLElement;
};

function createSelectState(): ReadySelectState {
  return {
    listEle: document.createElement('div')
  };
}

Defaults with ??

For values that can have a fallback:

const maxItems = $protected.maxVisibleListItems ?? 5;

Optional chaining for non-critical actions

Use ?. when skipping the action is acceptable.

$protected.resultEle?.classList.remove('error');

Avoid long-lived mutable global state when possible

A common improvement is to pass required values as function arguments instead of reading shared optional state.

function showList(listEle: HTMLElement) {
  listEle.classList.add('visible');
}

This is easier for both TypeScript and humans to reason about.

Common Mistakes

Mistake 1: Assuming assignment in one function guarantees safety everywhere

Broken example:

let state: { listEle?: HTMLElement } = {};

function init() {
  state.listEle = document.createElement('div');
}

function show() {
  state.listEle.classList.add('visible');
}

Why it is a problem:

  • show() could run before init()
  • the property is still typed as optional

Fix:

function show() {
  state.listEle?.classList.add('visible');
}

or:

function show() {
  const listEle = state.listEle;
  if (!listEle) return;
  listEle.classList.add('visible');
}

Mistake 2: Overusing !

state.listEle!.classList.add('visible');

This silences TypeScript, but it does not make the code safer.

Use ! only when you are certain the value exists.

Mistake 3: Making too many properties optional

If a property must exist in normal usage, consider making it required.

Less precise:

type State = {
  listEle?: HTMLElement;
};

Better when initialization is guaranteed:

type State = {
  listEle: HTMLElement;
};

Mistake 4: Using truthiness checks without understanding them

For DOM elements, this is fine:

if (state.listEle) {
  state.listEle.classList.add('visible');
}

But for values like numbers or empty strings, truthiness can hide valid values such as 0 or ''.

Use != null when you specifically mean not null and not undefined:

if (value != null) {
  // value is neither null nor undefined
}

Mistake 5: Ignoring API return types

querySelector returns Element | null, not always a valid element.

const el = document.querySelector('.list');
el.classList.add('visible'); // unsafe

Fix:

const el = document.querySelector('.list');
el?.classList.add('visible');

Comparisons

ApproachExampleBest whenTrade-off
if checkif (el) { el.classList.add('x') }You want safe explicit handlingMore verbose
Optional chainingel?.classList.add('x')Doing nothing is acceptable if missingSilent skip may hide bugs
Non-null assertionel!.classList.add('x')You know it must existUnsafe if wrong
Helper functionrequireEl().classList.add('x')Many places need the same guaranteeAdds a small abstraction
Better type designlistEle: HTMLElementInitialization can be guaranteed structurallyRequires redesign

Optional property vs required property

TypeMeaning
listEle?: HTMLElementProperty may be missing, so type is effectively `HTMLElement
listEle: HTMLElementProperty must exist

?. vs !

OperatorMeaning
?.Access only if the value exists; otherwise stop safely
!Tell TypeScript the value definitely exists

?. is safer. ! is stronger but riskier.

Take Quiz

Cheat Sheet

// Optional property
listEle?: HTMLElement
// means: HTMLElement | undefined

Safe ways to use possibly undefined values

if ($protected.listEle) {
  $protected.listEle.classList.add('visible');
}
$protected.listEle?.classList.add('visible');
$protected.listEle!.classList.add('visible');

Prefer guard clauses for larger functions

const listEle = $protected.listEle;
if (!listEle) return;

listEle.classList.add('visible');

Use ?? for default values

const max = $protected.maxVisibleListItems ?? 10;

Good rule of thumb

  • Use if when missing values are expected and need handling
  • Use ?. when skipping the action is fine
  • Use ! only when you are absolutely sure
  • Improve the type if the value should always exist

Common cause of TS2533

  • optional properties
  • null from DOM APIs
  • values initialized later
  • partial objects
  • mutable shared state

Best long-term fix

Model your state so required values are required at the point of use.

FAQ

Why does TypeScript still complain after I assigned the property earlier?

Because the property is still typed as optional, and TypeScript does not assume a mutable shared object stays initialized across different functions.

What is the safest fix for TS2533?

Usually a guard clause or explicit check is the safest fix.

Is the non-null assertion operator ! bad?

Not always. It is fine when you are truly certain a value exists, but it can cause runtime errors if that assumption is wrong.

Should I use optional chaining or an if statement?

Use optional chaining when it is okay to do nothing if the value is missing. Use an if statement when missing values need special handling.

Why is an optional property treated as undefined?

Because prop?: Type means the property may be omitted entirely, so TypeScript models it as Type | undefined.

How can I avoid repeated checks in many places?

Use helper functions, guard clauses, or redesign the state so required values become non-optional after initialization.

Can I just disable strict null checks?

You can, but it removes an important safety feature and usually leads to more runtime bugs. It is better to model the data correctly.

Take Quiz

Mini Project

Description

Build a small TypeScript module that manages a panel element which is created during initialization and shown later. This demonstrates how to safely work with values that may be undefined during setup but must exist before use.

Goal

Create a panel manager that initializes a DOM element and safely shows or hides it without disabling TypeScript null checks.

Requirements

  • Create a state object with an optional panelEl property.
  • Add an init() function that creates and stores the element.
  • Add a show() function that safely adds a CSS class.
  • Add a hide() function that safely removes a CSS class.
  • Use at least one pattern such as a guard clause, optional chaining, or a helper assertion function.
Take Quiz

Keep learning

Related questions

View more