跳转到内容

null vs. undefined

null 表示:

这个字段存在,而且它被明确地设置为空。

undefined 表示:

这个值没有被设置,或者这个概念在这里根本不存在。

听起来像是在咬文嚼字。

其实不是。

这是 Business Semantics 最容易悄悄掉进空洞里的地方之一。

都是“空”,但并不是同一种空。

在很多高级语言里,null 已经够麻烦了。

JavaScript 和 TypeScript 更特别,因为它们有两个不同的“没有值”概念:

null;
undefined;

而它们的语义不同。

null 是一个显式值。

const deletedAt = null;

它表达的是:

这个字段存在,并且被有意识地设置为空。

undefined 则经常出现在一个值尚未初始化、不存在或没有被传入的情况下。

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

它更接近:

这里没有设置任何值。

这不是学术区别,而是语义区别。

可以先用下面这条工作规则:

  • null:显式的空值
  • undefined:未设置、不存在或未提供

例如:

type User = {
id: string;
name: string;
// Der Nutzer kann gelöscht sein.
// Wenn nicht gelöscht, ist der Wert bewusst leer.
deletedAt: Date | null;
// Dieses Feld ist optional.
// Es muss nicht existieren.
lastLogin?: Date;
};

deletedAt: null 表示:

用户没有被删除。

lastLogin: undefined 或字段缺失,则意味着:

当前没有这个 Login Value,或者它根本没有被提供。

这是两个不同的事实。

这种区别不应该偶然产生。

如果 API 返回 null,但 Frontend 只检查 undefined,通常不会出现一个非常响亮的异常。

程序只是悄悄做错事。

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

然后 API 返回:

const dto = {
assigned_to_user_id: null,
};

于是 null 不再表示“未分配”,而会穿过一段从未为它设计过的逻辑。

没有 Crash。 没有 Exception。 只有错误行为。

这种错误反而比响亮失败更危险。

一个常见捷径是:

if (value) {
// Wert ist da
}

很方便。

但它把多个不同情况混在一起:

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

问题不是 JavaScript 的结果不正确。

而是判断太粗。

如果 0 是一个有效值,就不能把它当成“没有值”。

function formatCount(count: number | null): string {
if (!count) {
return 'Keine Einträge';
}
return `${count} Einträge`;
}

这里真正的业务问题是:

0 是一个有效结果,还是意味着“无值”?

如果 0 是有效值,if (!count) 就错了。

更明确的写法:

function formatCount(count: number | null): string {
if (count === null) {
return 'Keine Daten';
}
return `${count} Einträge`;
}

如果你明确希望同时处理 nullundefined

if (count == null) {
// trifft null und undefined
}

这是少数 == null 可以被有意识使用的场景。

重点是:要有意识。

不是碰巧。

Truthy/Falsy 陷阱。

另一个经典例子:

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

这会 Crash。

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

undefined 也一样。

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

同样会失败。

所以常见的防御写法是:

const { name } = user ?? {};

它能避免 Crash。

但也需要小心。

因为这样会把:

user ist null

悄悄转换成:

user ist leeres Objekt

这可能完全合理。

也可能把业务语义盖住。

如果 user === null 的含义是“没有分配用户”,那更好的代码也许是显式处理:

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

代码更长。

但它说出了事实。

这是 JavaScript 里非常容易踩的坑之一。

Default Value 会在 undefined 时生效。

不会在 null 时生效。

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

Destructuring 也一样:

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

很多人会期待这里得到 'Gast'

但 Default 不会触发,因为 name 这个字段存在,只是其值为 null

这恰好体现了语义差别:

  • undefined:值缺失 → Default 生效
  • null:值明确为空 → Default 不生效

如果你希望把 null 也视为“缺失”,可以使用 Nullish Coalescing:

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

它会处理 nullundefined

但不会误伤 ''0false

也因此,很多场景下 ??|| 更合适。

const countLabel = count || 10; // 0 wird zu 10
const countLabel = count ?? 10; // nur null/undefined wird zu 10

Default Value 的差异。

API、Domain 和 UI 必须做出语义决定

Section titled “API、Domain 和 UI 必须做出语义决定”

真正的问题不是 null 存在。

问题是没有人决定它代表什么。

API 返回:

assigned_to_user_id: null;

Domain 里写:

assignee?: User

UI 里检查:

if (!assignee) ...

Mapper 再写:

assigneeLabel: dto.assigned_to_user_id || 'Unzugewiesen';

现在语义已经散落在整个系统中。

到底是“没有分配用户”? 字段没有加载? ID 是空的? API 出错? 还是这是完全合法的状态?

很快就没人能准确回答。

因此最重要的规则是:

在每一条边界上明确决定 nullundefined 的含义。

例如:

  • API 可以用 null 表示“字段存在,但明确为空”
  • DTO 忠实反映 API Contract
  • Mapper 把 null 翻译成业务含义
  • Domain Model / ViewModel 尽量避免不必要的 Nullability
  • Component 尽量得到语义明确的值

没有必要全面禁止 null

null 可以非常有用。

例如:

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

这里的 null 很清晰:

这个字段总是存在,但当前用户没有被删除。

或者:

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

这同样可以很合理:

一项 Task 可以明确处于“无人分配”的状态。

这是业务语义。

真正危险的是 null 只表示:

反正这里是空的,祝你好运。

很多较新的建模方式会尽量减少 null

不是因为 null 本身邪恶。

而是因为 null 很容易隐藏含义。

一种替代方式是显式建模状态。

例如 Union Type:

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

这样就不需要猜 null 的含义。

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

加载状态也是一样:

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

这通常比下面的形式更清晰:

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

因为多个 nullable 字段很容易产生无法解释的组合:

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

这代表什么?

还没加载? 加载完成,但没有 User? 错误被忘了? 还是合法的 Empty State?

Union Type 会迫使这些状态变得显式。

Null State 与显式状态模型。

TypeScript 可以帮忙——前提是你允许它帮

Section titled “TypeScript 可以帮忙——前提是你允许它帮”

TypeScript 能让这些区别在编译期变得可见。

但前提是打开 strictNullChecks

启用后,下面这些类型彼此不同:

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

这迫使代码作出决定。

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

在没有处理 null 的情况下,它不会通过编译。

更好的写法可能是:

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

或者:

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

具体写法不是重点。

重点是:空值情况被迫显式出现。

一个很实用的经验是:

DTO 可以包含 null。ViewModel 应该尽量减少 null

为什么?

DTO 表达外部现实。

如果 API 返回 null,DTO 就应该诚实地表示 null

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

但 ViewModel 应该尽可能不给 UI 留谜题。

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

这样 Template 根本无需知道 null 在 API 中代表什么。

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

这很无聊。

也正因为如此,它很好。

null 并不天然有问题。

真正危险的是一个没有明确含义的 null 在系统里一路流动。

nullundefined 的差异并不学术:

  • null 是显式空值
  • undefined 表示未设置或不存在
  • Default Value 对 undefined 生效,但不会对 null 生效
  • Truthy / Falsy Check 会模糊业务差异
  • nullundefined 做 Destructuring 会失败
  • Union Type 往往比多个 nullable 字段更清晰

简短规则:

当“空”本身具有业务含义时使用 null;当值尚未设置或不存在时使用 undefined。并在清晰的边界上,把两者翻译成语义明确的模型。

再直接一点:

null 可以存在。没有意义的 null 才是架构泄漏。