关于 push-null-to-perimeter
这项 TypeScript 技能指导开发者将相关的可空值设计为同时存在或同时缺失,以减少分散的空值检查,适用于类型设计和可选属性重构。
主要功能
- 01将相关值组合为全有或全无类型
- 02用判别联合表示部分状态
- 03将空值处理留在系统边界
- 04用工厂处理异步初始化
适用场景
- 01重构含相关可选字段的类型
- 02减少分散的空值检查
- 03建模加载与错误状态
- 04避免类处于部分初始化状态
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ marius-townhouse/ effective-typescript-skills --skill push-null-to-perimeter安装后在 Claude Code 中输入/push-null-to-perimeter确认已加载。
详细介绍
将空值推向类型的边界
概览
设计类型,使值要么完全为空,要么完全非空,而不是混杂状态。
混杂的空值状态会形成隐式关系,难以追踪,还会导致空值检查散布各处并引发错误。
何时使用此技能
- 设计包含多个可空字段的类型
- 发现空值检查散布在代码各处
- 相关值会同时为空
- 调试“undefined 不是对象”错误
- 重构包含许多可选属性的类型
铁律
NEVER design types where null values have implicit relationships.
没有例外:
- 不能因为“这样更简单”
- 不能因为“我们会在运行时检查”
- 不能因为“这些字段彼此独立”
检测:“混杂空值”异味
如果两个值总是同时为空或同时非空,就应在类型中表达这种关系。
// ❌ VIOLATION: Implicit relationship between min and max
function extent(nums: number[]) {
let min: number | undefined;
let max: number | undefined;
for (const num of nums) {
if (min === undefined) {
min = num;
max = num;
} else {
min = Math.min(min, num);
max = Math.max(max, num); // Error! max might be undefined
}
}
return [min, max]; // [number | undefined, number | undefined]
}
// Caller has to deal with all four combinations:
const [min, max] = extent([1, 2, 3]);
// min defined + max defined
// min undefined + max undefined
// min defined + max undefined <- Impossible but allowed by type!
// min undefined + max defined <- Impossible but allowed by type!
解决方案:非此即彼的类型
// ✅ CORRECT: Result is either fully present or fully absent
function extent(nums: number[]): [number, number] | null {
let result: [number, number] | null = null;
for (const num of nums) {
if (!result) {
result = [num, num];
} else {
result = [Math.min(num, result[0]), Math.max(num, result[1])];
}
}
return result;
}
// Caller only has two cases:
const result = extent([1, 2, 3]);
if (result) {
const [min, max] = result; // Both guaranteed to exist
}
示例:带有帖子内容的用户
// ❌ BAD: Mixed nullability
class UserPosts {
user: UserInfo | null;
posts: Post[] | null;
constructor() {
this.user = null;
this.posts = null;
}
async init(userId: string) {
this.user = await fetchUser(userId);
this.posts = await fetchPosts(userId);
}
}
// At any moment, four states are possible:
// user null + posts null (before init)
// user null + posts non-null (during init - race condition!)
// user non-null + posts null (during init - race condition!)
// user non-null + posts non-null (after init)
// ✅ GOOD: All-or-nothing
class UserPosts {
user: UserInfo;
posts: Post[];
private constructor(user: UserInfo, posts: Post[]) {
this.user = user;
this.posts = posts;
}
static async create(userId: string): Promise<UserPosts> {
const [user, posts] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
]);
return new UserPosts(user, posts);
}
}
// Only two states: no instance, or fully loaded instance
const userPosts = await UserPosts.create(userId);
console.log(userPosts.user.name); // Always safe!
示例:API 响应
// ❌ BAD: Data and error both optional
interface ApiResponse {
data?: ResponseData;
error?: Error;
loading: boolean;
}
// Confusing states are possible:
const bad: ApiResponse = {
data: someData,
error: someError, // Both data AND error?
loading: true, // Still loading but has data?
};
// ✅ GOOD: Each state is complete
type ApiResponse =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: ResponseData }
| { status: 'error'; error: Error };
// No confusion possible:
function handleResponse(response: ApiResponse) {
switch (response.status) {
case 'idle':
return null;
case 'loading':
return <Spinner />;
case 'success':
return <Data data={response.data} />; // data guaranteed
case 'error':
return <Error error={response.error} />; // error guaranteed
}
}
边界模式
在系统边界处理可空性,而不是让它贯穿整个系统:
// ❌ BAD: Null checks everywhere
function processUser(userId: string | null) {
if (!userId) return null;
const user = getUser(userId);
if (!user) return null;
const posts = getPosts(user);
if (!posts) return null;
return formatUserWithPosts(user, posts);
}
// ✅ GOOD: Check at boundary, then work with clean types
function processUser(userId: string | null): UserWithPosts | null {
// Handle null at the boundary
if (!userId) return null;
const user = getUser(userId);
if (!user) return null;
// After validation, work with non-null types
return formatUserWithPosts(user); // Takes User, not User | null
}
function formatUserWithPosts(user: User): UserWithPosts {
// No null checks needed inside - user is guaranteed non-null
const posts = user.posts; // Always exists
return { ...user, posts: posts.map(formatPost) };
}
类设计:要么完全初始化,要么完全不初始化
// ❌ BAD: Partially initialized state
class Connection {
socket: Socket | null = null;
protocol: Protocol | null = null;
async connect() {
this.socket = await createSocket();
this.protocol = await negotiateProtocol(this.socket);
}
send(data: string) {
if (!this.socket || !this.protocol) {
throw new Error('Not connected');
}
this.protocol.send(this.socket, data);
}
}
// ✅ GOOD: Factory ensures complete initialization
class Connection {
private constructor(
private socket: Socket,
private protocol: Protocol,
) {}
static async create(): Promise<Connection> {
const socket = await createSocket();
const protocol = await negotiateProtocol(socket);
return new Connection(socket, protocol);
}
send(data: string) {
// No null checks - always initialized
this.protocol.send(this.socket, data);
}
}
抵御压力的规范
1. “我们需要中间状态”
压力:“所有数据加载完成之前,对象就需要存在”
**回应:**为中间状态创建单独的类型,或使用工厂。
**行动:**使用可辨识联合或异步工厂。
2. “这样更复杂”
压力:“使用带可选字段的单个类型更简单”
**回应:**散布各处的空值检查比清晰的类型更复杂。
**行动:**提前投入精力设计类型。
危险信号——停止并重新考虑
- 多个会同时为空的可选字段
- 空值检查散布在类的各处
- 异步初始化中的竞态条件
- 类似“仅当 Y 已设置时才设置 X”的注释
- 类型允许出现的“不可能”状态
常见的自我开脱(都不成立)
| 借口 | 事实 |
|---|---|
| “这样更简单” | 散布各处的空值检查并不简单。 |
| “我们会在运行时检查” | 类型能在编译时捕获错误。 |
| “字段彼此独立” | 如果它们总是同时为空,那它们就有关联。 |
快速参考
| 模式 | 解决方案 |
|---|---|
| 两个值同时为空 | 返回元组或空值 |
| 带加载状态的对象 | 使用可辨识联合 |
| 带异步初始化的类 | 使用静态工厂方法 |
| 混杂的可空属性 | 归入嵌套对象 |
总结
让空值成为非此即彼的选择。
设计类型,让值要么完全存在,要么完全不存在。将空值处理推向代码边界。这样能让类型更清晰、减少空值检查,也减少错误。
参考资料
基于 Dan Vanderkam 所著的《Effective TypeScript》,第 33 条:将空值推向类型的边界。
评论
支持 Markdown