mcpField
V
valid-state-types

valid-state-types

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

关于 valid-state-types

valid-state-types 是面向应用开发者的编程技能,指导使用类型表达有效状态,避免无效字段组合。重点采用判别联合,并将必须同步出现的相关字段组合建模。

主要功能

  1. 01识别无效状态设计气味
  2. 02用判别联合表示状态
  3. 03组合必须共同出现的字段
  4. 04以单一事实来源建模

适用场景

  1. 01设计请求加载与结果状态
  2. 02重构含多个依赖布尔值的类型
  3. 03建模购物车等多阶段状态

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/marius-townhouse/effective-typescript-skills --skill valid-state-types

安装后在 Claude Code 中输入/valid-state-types确认已加载。

详细介绍

优先使用始终表示有效状态的类型

概述

设计类型,让它们只能表示有效状态。

如果类型允许无效的值组合,代码就会更难编写、更难阅读,也更容易出现 bug。

何时使用此方法

  • 为应用设计状态类型
  • 创建包含相关字段的类型
  • 为具有依赖关系的状态建模
  • 调试不可能状态错误
  • 重构令人困惑的类型定义

铁律

NEVER design types that can represent invalid states.

绝不例外:

  • 不能因为“这样更简单”
  • 不能因为“我们会在运行时验证”
  • 不能因为“额外的类型太麻烦”

检测:“无效状态”征兆

如果类型允许本不该出现的组合,就重新设计它。

// ❌ VIOLATION: Allows invalid states
interface RequestState {
  status: 'loading' | 'success' | 'error';
  data?: string;
  error?: string;
}

// These are all valid according to the type, but nonsensical:
const bad1: RequestState = { status: 'success' };           // Where's the data?
const bad2: RequestState = { status: 'error' };             // Where's the error?
const bad3: RequestState = { status: 'loading', data: 'x' }; // Loading but has data?
const bad4: RequestState = { status: 'success', error: 'x' }; // Success with error?

解决方案:判别联合

// ✅ CORRECT: Only valid states are representable
interface RequestPending {
  status: 'pending';
}
interface RequestLoading {
  status: 'loading';
}
interface RequestSuccess {
  status: 'success';
  data: string;
}
interface RequestError {
  status: 'error';
  error: string;
}

type RequestState = RequestPending | RequestLoading | RequestSuccess | RequestError;

// Now invalid states are impossible:
const bad: RequestState = { status: 'success' };
//    ~~~ Property 'data' is missing in type '{ status: "success"; }'

真实示例:页面状态

// ❌ BAD: Implicit relationships, invalid states possible
interface PageState {
  isLoading: boolean;
  error?: string;
  currentPage: string;
  data?: PageData;
}

// What does this mean?
const confusing: PageState = {
  isLoading: true,
  error: 'Network error',
  currentPage: '/home',
  data: someData,  // Loading but has data? Has error but also data?
};

// ✅ GOOD: Explicit states, no invalid combinations
interface PagePending { state: 'pending' }
interface PageLoading { state: 'loading'; currentPage: string }
interface PageLoaded { state: 'loaded'; currentPage: string; data: PageData }
interface PageError { state: 'error'; currentPage: string; error: string }

type PageState = PagePending | PageLoading | PageLoaded | PageError;

// Now the render function is clear:
function renderPage(state: PageState) {
  switch (state.state) {
    case 'pending':
      return renderPending();
    case 'loading':
      return renderSpinner(state.currentPage);
    case 'loaded':
      return renderData(state.data);
    case 'error':
      return renderError(state.error);
  }
}

相关字段应该放在一起

// ❌ BAD: Related fields can be independently undefined
interface Person {
  name: string;
  placeOfBirth?: string;  // These should either both
  dateOfBirth?: Date;     // be present or both absent
}

// This is valid but probably wrong:
const person: Person = { name: 'Alice', placeOfBirth: 'NYC' };  // No date?

// ✅ GOOD: Group related fields
interface Person {
  name: string;
  birth?: {
    place: string;
    date: Date;
  };
}

// Now they travel together:
function printBirth(person: Person) {
  if (person.birth) {
    // Both place AND date are guaranteed to exist
    console.log(`Born in ${person.birth.place} on ${person.birth.date}`);
  }
}

法航 447 号航班反模式

一个糟糕状态设计造成的悲剧示例:

// ❌ DANGEROUS: Independent controls with conflicting states
interface CockpitControls {
  leftSideStick: number;   // Pilot's stick position
  rightSideStick: number;  // Copilot's stick position
}

// What if they conflict? The code has to decide somehow.
function getStickSetting(controls: CockpitControls): number {
  // Average them? Return left? Return right? No good answer!
  return (controls.leftSideStick + controls.rightSideStick) / 2;
}

// ✅ SAFE: Single source of truth
interface CockpitControls {
  stickAngle: number;  // One stick, one truth
}

抗压应对方案

1. “类型越多,工作越多”

压力:“创建这些接口太繁琐了”

**回应:**无效状态会导致 bug。定义类型反而是容易的部分。

**行动:**提前投入时间,之后就能节省调试时间。

2. “我们会在运行时验证”

压力:“我们会在代码中检查无效组合”

**回应:**每个使用者都得记得验证,但他们不会记得。

**行动:**让无效状态无法表示,从而不再需要验证。

3. “这只是内部状态”

压力:“没有外部代码会使用这个类型”

**回应:**内部代码也是代码,仍然会有 bug。

**行动:**为所有地方设计良好的类型。

危险信号——停止并重新考虑

  • 多个相互依赖的布尔标记
  • 本应同时出现的可选字段
  • 带有可选数据字段的状态枚举
  • 解释“如果有 X,就必须设置 Y”的注释
  • 检查不可能组合的验证代码
  • 带有“理论上不会发生”默认分支的 switch 语句

常见的自我辩解(均不成立)

借口事实
“这样更简单”在出现 bug 之前确实更简单。
“我们会很谨慎”人总会疏忽,类型不会忘记。
“我们会写文档”文档会过时,类型不会。
“接口太多了”总比 bug 太多好。

快速参考

不好的模式好的模式
多个相关的可选字段可选的嵌套对象
状态字符串 + 可选数据/错误判别联合
相互依赖的布尔标记判别联合
多个彼此独立的事实来源单一事实来源

设计有效状态类型

  1. 列出系统可能处于的所有状态
  2. 针对每种状态,确定需要哪些数据
  3. 为每种状态创建一个接口
  4. 使用判别联合将它们组合起来
  5. **验证:**能否构造任何无效状态?
// Example: Shopping Cart
interface EmptyCart { state: 'empty' }
interface ActiveCart { state: 'active'; items: CartItem[] }
interface CheckoutCart { state: 'checkout'; items: CartItem[]; payment: PaymentInfo }
interface CompletedCart { state: 'completed'; orderId: string }

type ShoppingCart = EmptyCart | ActiveCart | CheckoutCart | CompletedCart;

总结

如果无效状态可以表示,它们就会出现。

设计只能表示有效状态的类型。使用判别联合,将相关字段归为一组。这样代码会更容易编写、更容易理解,也更难出错。

参考资料

基于 Dan Vanderkam 的《Effective TypeScript》第 29 条:优先使用始终表示有效状态的类型。

评论

支持 Markdown