关于 valid-state-types
valid-state-types 是面向应用开发者的编程技能,指导使用类型表达有效状态,避免无效字段组合。重点采用判别联合,并将必须同步出现的相关字段组合建模。
主要功能
- 01识别无效状态设计气味
- 02用判别联合表示状态
- 03组合必须共同出现的字段
- 04以单一事实来源建模
适用场景
- 01设计请求加载与结果状态
- 02重构含多个依赖布尔值的类型
- 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 太多好。 |
快速参考
| 不好的模式 | 好的模式 |
|---|---|
| 多个相关的可选字段 | 可选的嵌套对象 |
| 状态字符串 + 可选数据/错误 | 判别联合 |
| 相互依赖的布尔标记 | 判别联合 |
| 多个彼此独立的事实来源 | 单一事实来源 |
设计有效状态类型
- 列出系统可能处于的所有状态
- 针对每种状态,确定需要哪些数据
- 为每种状态创建一个接口
- 使用判别联合将它们组合起来
- **验证:**能否构造任何无效状态?
// 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