Skip to content

null vs. undefined

null means:

This field exists. And it’s deliberately empty.

undefined means:

This value was never set. Or this concept doesn’t exist in this context.

That sounds like splitting hairs.

It isn’t, though.

It’s one of the most common places where domain meaning quietly falls through the cracks.

Both are empty — but not the same.

In a lot of high-level languages, null is already tricky enough.

JavaScript and TypeScript complicate matters further, because there are two different “no value” concepts:

null;
undefined;

And they don’t mean the same thing.

null is an explicit value.

const deletedAt = null;

That says:

The field is present. It was deliberately set to empty.

undefined, on the other hand, often comes up when something wasn’t initialized, doesn’t exist, or wasn’t passed in.

let deletedAt;
console.log(deletedAt); // undefined

That means:

No value was set here.

This distinction isn’t academic. It’s semantic.

As a pragmatic working rule:

  • null: an explicit blank
  • undefined: not set, not present, or not delivered

Examples:

type User = {
id: string;
name: string;
// The user can be deleted.
// If not deleted, the value is deliberately empty.
deletedAt: Date | null;
// This field is optional.
// It doesn't have to exist.
lastLogin?: Date;
};

deletedAt: null means:

The user hasn’t been deleted.

lastLogin: undefined, or missing entirely, means:

There’s no login value for this user, or the field wasn’t delivered.

That’s a difference.

And that difference shouldn’t happen by accident.

When an API returns null and the frontend code only checks for undefined, you often don’t get a loud error.

The wrong thing just happens.

type UserDto = {
assigned_to_user_id: string | null;
};
function getAssigneeLabel(userId: string | undefined): string {
if (userId === undefined) {
return 'Unassigned';
}
return `User ${userId}`;
}

Then the API sends:

const dto = {
assigned_to_user_id: null,
};

And suddenly null isn’t “Unassigned” — it runs through logic that was never built for it.

No crash. No exception. Just wrong behavior.

That’s more dangerous than a loud error.

A popular shortcut is:

if (value) {
// value is present
}

That’s convenient.

But it mixes up different cases:

!!null; // false
!!undefined; // false
!!''; // false
!!0; // false
!!false; // false

The problem isn’t that this evaluation is wrong.

The problem is that it’s too coarse.

If 0 is a valid value, it can’t be treated like “no value.”

function formatCount(count: number | null): string {
if (!count) {
return 'No entries';
}
return `${count} entries`;
}

That looks harmless.

But with 0, the question in business terms is:

Is 0 a valid value, or does 0 mean “nothing”?

If 0 is a valid value, if (!count) is wrong.

Better:

function formatCount(count: number | null): string {
if (count === null) {
return 'No data';
}
return `${count} entries`;
}

Or, if null and undefined should be handled together:

if (count == null) {
// matches null and undefined
}

This is one of the few cases where == null can be deliberately useful.

But then, please, on purpose.

Not by accident.

Truthy/falsy trap

Another classic:

const user = null;
const { name } = user;

That crashes.

Cannot destructure property 'name' of 'user' as it is null.

The same is true for undefined.

const user = undefined;
const { name } = user;

That breaks too.

That’s why you often see:

const { name } = user ?? {};

That prevents the crash.

But you have to be careful here too.

Because that turns:

user is null

suddenly into:

user is an empty object

That can be correct.

But it can also hide business semantics.

If user === null means “no user assigned,” then maybe that meaning should be handled explicitly:

if (user === null) {
return 'Unassigned';
}
const { name } = user;

The code is longer.

But it tells the truth.

This is one of the meanest JavaScript traps.

Default values kick in for undefined.

Not for null.

function greet(name = 'Guest') {
return `Hello ${name}`;
}
greet(undefined); // "Hello Guest"
greet(null); // "Hello null"

The same thing happens with destructuring:

const user = {
name: null,
};
const { name = 'Guest' } = user;
console.log(name); // null

A lot of people expect 'Guest' here.

But the default doesn’t kick in, because name is present. The value is just null.

That’s exactly the semantics:

  • undefined: value is missing → default kicks in
  • null: value is deliberately empty → default doesn’t kick in

If you want to treat null like “missing,” you need nullish coalescing:

const label = user.name ?? 'Guest';

That handles null and undefined.

But not '', 0, or false.

And that’s exactly why ?? is often better than ||.

const countLabel = count || 10; // 0 becomes 10
const countLabel = count ?? 10; // only null/undefined becomes 10

Default values

The real problem doesn’t come from null existing.

The problem comes from nobody deciding what it means.

An API returns:

assigned_to_user_id: null;

The domain model represents it as:

assignee?: User

The UI checks:

if (!assignee) ...

And a mapper does:

assigneeLabel: dto.assigned_to_user_id || 'Unassigned';

Now the semantics are scattered.

Is the user unassigned? Did the field not get loaded? Is the ID empty? Is this an API error? Is it a valid state?

Nobody knows for sure anymore.

That’s why the most important rule is:

Decide what null and undefined mean, per boundary.

For example:

  • The API is allowed to return null when a field is explicitly empty.
  • DTOs honestly reflect the API.
  • Mappers translate null into explicit domain meaning.
  • Domain models and ViewModels avoid unnecessary nullability.
  • Components get the clearest possible values.

You don’t have to ban null outright.

null can make a lot of sense.

For example:

type User = {
deletedAt: Date | null;
};

Here, null is good:

This field always exists. But the user isn’t deleted.

Or:

type Task = {
assigneeId: string | null;
};

This can make sense too:

A task can be deliberately assigned to nobody.

That’s a business statement.

null becomes a problem when it only means:

Something’s just empty. Good luck.

Then it gets dangerous.

Modern approaches often avoid null on purpose

Section titled “Modern approaches often avoid null on purpose”

A lot of more modern modeling approaches try to avoid null as much as possible.

Not because null is evil.

But because null hides meaning.

Instead, you model states explicitly.

For example, with union types:

type Assignee = { kind: 'assigned'; userId: string; name: string } | { kind: 'unassigned' };

Then nobody has to guess what null means.

function getAssigneeLabel(assignee: Assignee): string {
switch (assignee.kind) {
case 'assigned':
return assignee.name;
case 'unassigned':
return 'Unassigned';
}
}

Or for loading states:

type UserState = { status: 'idle' } | { status: 'loading' } | { status: 'loaded'; user: User } | { status: 'error'; message: string };

That’s often better than:

user: User | null;
loading: boolean;
error: string | null;

Because with several nullable fields, impossible states appear quickly:

{
user: null,
loading: false,
error: null,
}

What does that mean?

Not loaded yet? Loaded, but no user? Error forgotten? Empty state?

Union types make states like this explicit.

Null state vs. explicit state

TypeScript can make these differences visible.

But only if strictNullChecks is enabled.

With strictNullChecks, these types are different:

string;
string | null;
string | undefined;
string | null | undefined;

That forces you to make a decision.

function formatName(name: string | null): string {
return name.toUpperCase();
}

That won’t compile without handling null.

Better:

function formatName(name: string | null): string {
if (name === null) {
return 'Unknown';
}
return name.toUpperCase();
}

Or:

function formatName(name: string | null): string {
return name?.toUpperCase() ?? 'Unknown';
}

The exact syntax isn’t what matters.

What matters: the empty case becomes visible.

For frontend architectures, this rule has proven itself:

DTOs are allowed to contain null. ViewModels should contain as little null as possible.

Why?

DTOs reflect external reality.

If the API returns null, the DTO is allowed to show null.

type TaskDto = {
assigned_to_user_id: string | null;
};

But the ViewModel should give the UI as few riddles as possible.

type TaskViewModel = {
assigneeLabel: string;
isAssigned: boolean;
};

Then the template doesn’t need to know what null means.

<span>{{ task.assigneeLabel }}</span>

That’s boring.

And that’s exactly why it’s good.

null isn’t fundamentally bad.

But null is dangerous when it travels through the system without a clear meaning.

The difference between null and undefined isn’t academic:

  • null represents an explicit absence.
  • undefined is not set or not present.
  • Default values kick in for undefined, but not for null.
  • Truthy/falsy checks blur business distinctions.
  • Destructuring on null or undefined breaks.
  • Union types often make states clearer than several nullable fields.

The short rule:

Use null when an explicitly empty value has domain meaning. Use undefined when something isn’t set or isn’t present. And translate both, at clear boundaries, into unambiguous models.

Or, even more bluntly:

null is allowed. Meaningless null is an architecture leak.