mcpField
P
push-null-to-perimeter

push-null-to-perimeter

github.com/marius-townhouse开发644 次安装38 周前更新
代码仓库

关于 push-null-to-perimeter

这项 TypeScript 技能指导开发者将相关的可空值设计为同时存在或同时缺失,以减少分散的空值检查,适用于类型设计和可选属性重构。

主要功能

  1. 01将相关值组合为全有或全无类型
  2. 02用判别联合表示部分状态
  3. 03将空值处理留在系统边界
  4. 04用工厂处理异步初始化

适用场景

  1. 01重构含相关可选字段的类型
  2. 02减少分散的空值检查
  3. 03建模加载与错误状态
  4. 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