驾驭不确定:联合类型如何让TypeScript代码更诚实

29 阅读4分钟

从“可能崩溃”到“必须安全”的类型革命

在传统开发中,我们经常编写“说谎”的代码:

// ❌ 我们在说谎!
interface User {
  id: number;
  name: string;
}

function findUser(id: number): User {
  // 但实际上:可能找不到用户!
  return database.findUser(id); // 可能返回 undefined
}

const user = findUser(123);
console.log(user.name); // 💥 运行时崩溃!

问题根源:类型系统与运行时脱节

我们面临的核心问题是:类型声明无法反映真实世界的不确定性

// 这些类型都在说谎:
interface ApiResponse {
  data: User;     // 但请求可能失败!
  error?: string; // 模糊的optional无法表达互斥关系
}

function parseInput(input: string): number {
  // 但输入可能无法解析!
  return parseInt(input); // 可能返回 NaN
}

这种脱节导致:

  • 运行时崩溃:类型说“一定有”,运行时说“其实没有”
  • 复杂防御代码:到处都是 if (x && x.y && x.y.z)
  • 业务逻辑模糊:状态关系靠注释,而不是代码表达

解决方案:联合类型 + 类型收窄

1. 基础联合:表达“或”的关系

// ✅ 说出真相:这个值可能是字符串或数字
type ID = string | number;

// ✅ 明确表达:函数可能成功或失败
function findUser(id: number): User | undefined {
  return database.findUser(id);
}

const user = findUser(123);
// ❌ TypeScript报错:Object is possibly 'undefined'
console.log(user.name);

// ✅ 必须显式处理
if (user) {
  console.log(user.name); // 这里user被收窄为User类型
} else {
  console.log('用户不存在');
}

2. 字面量联合:有限的选项集合

// 明确的选项,杜绝无效值
type ButtonSize = 'small' | 'medium' | 'large';
type Theme = 'light' | 'dark' | 'auto';

function setTheme(theme: Theme) {
  // 不用检查theme是否是有效值,TypeScript已经保证!
  applyTheme(theme);
}

setTheme('light');    // ✅
setTheme('unknown');  // ❌ TypeScript编译错误

3. 可辨识联合:让不可能的状态变得不可能

这是联合类型的杀手级特性:

// ❌ 传统方式:矛盾的状态可能共存
interface FormState {
  isLoading: boolean;
  data?: User[];
  error?: string;
  // 可能同时存在:isLoading=true, error="xxx", data=[...]
}

// ✅ 联合类型:排除矛盾状态
type FormState = 
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; message: string };

// 现在不可能同时处于loading和error状态!

实战:重构复杂业务逻辑

Before:脆弱的条件判断

function handleApiResponse(response) {
  // 需要记住所有可能的字段组合
  if (response.success && response.data) {
    return response.data.map(item => item.name);
  } else if (response.error) {
    if (response.error.code === 404) {
      return '未找到';
    }
    // 容易遗漏其他错误码...
  } else if (response.loading) {
    return '加载中...';
  }
  // 可能忘记处理其他情况...
}

After:完整的状态机

type ApiResponse = 
  | { type: 'success'; data: User[] }
  | { type: 'error'; message: string; code: number }
  | { type: 'loading'; progress: number }
  | { type: 'idle' };

function handleApiResponse(response: ApiResponse) {
  switch (response.type) {
    case 'success':
      return response.data.map(user => user.name);
    case 'error':
      return `错误 ${response.code}: ${response.message}`;
    case 'loading':
      return `加载中... ${response.progress}%`;
    case 'idle':
      return '准备就绪';
    default:
      // 如果未来添加新类型,这里会报错!
      const exhaustiveCheck: never = response;
      return exhaustiveCheck;
  }
}

类型收窄:安全访问的四种方式

1. typeofinstanceof

function process(value: string | number) {
  if (typeof value === 'string') {
    return value.toUpperCase(); // value被收窄为string
  } else {
    return value.toFixed(2); // value被收窄为number
  }
}

2. in 操作符

interface Dog { bark(): void; }
interface Cat { meow(): void; }

function handlePet(pet: Dog | Cat) {
  if ('bark' in pet) {
    pet.bark(); // pet被收窄为Dog
  } else {
    pet.meow(); // pet被收窄为Cat
  }
}

3. 自定义类型守卫

function isStringArray(value: unknown): value is string[] {
  return Array.isArray(value) && value.every(item => typeof item === 'string');
}

function processData(data: unknown) {
  if (isStringArray(data)) {
    // data被收窄为string[]
    return data.map(s => s.trim());
  }
}

React实战:表单状态管理

type FormState = 
  | { type: 'idle' }
  | { type: 'submitting'; data: FormData }
  | { type: 'success'; result: Order }
  | { type: 'error'; message: string; field?: string };

const ContactForm: React.FC = () => {
  const [state, setState] = useState<FormState>({ type: 'idle' });

  const handleSubmit = async (data: FormData) => {
    setState({ type: 'submitting', data });
    
    try {
      const result = await api.submit(data);
      setState({ type: 'success', result });
    } catch (error) {
      setState({ type: 'error', message: error.message });
    }
  };

  return (
    <form>
      {/* 编译器保证我们处理了所有状态 */}
      {state.type === 'submitting' && <Spinner />}
      {state.type === 'success' && <Confirmation order={state.result} />}
      {state.type === 'error' && <ErrorMessage message={state.message} />}
      
      <button 
        disabled={state.type === 'submitting'}
        onClick={() => handleSubmit(formData)}
      >
        提交
      </button>
    </form>
  );
};

为什么这是编程范式的升级?

传统开发:事后检查

写代码 → 运行测试 → 发现边缘情况 → 打补丁 → 引入新bug

联合类型:事前预防

定义完整类型 → 编译器检查所有情况 → 编写安全代码 → 运行时稳定

核心价值对比

维度传统方式联合类型
安全性运行时发现错误编译时防止错误
可维护性靠注释和记忆类型即文档
重构信心担心破坏隐藏逻辑编译器保证完整性
业务表达逻辑藏在代码里类型表达业务规则

开始行动

第一步:识别“说谎”的代码

// 找到这些模式:
interface X { data: Y } // 但data可能不存在
function f(): Z         // 但可能返回null/undefined

第二步:用联合类型说出真相

// 改为:
type X = { data: Y } | { error: string }
function f(): Z | null

第三步:享受编译器保驾护航

// TypeScript会成为你的结对编程伙伴:
// - 提醒你处理所有情况
// - 阻止你访问可能不存在的属性  
// - 保证重构的安全性

对!你抓住了联合类型的精髓:预先设计的精确性

联合类型 vs 传统方式的本质区别

传统方式:运行时猜测

function processData(data: any) {
  // 编码时:❌ 不知道data具体结构
  // 运行时:🤔 猜测并检查
  if (data && typeof data === 'object') {
    if ('items' in data && Array.isArray(data.items)) {
      return data.items.map(item => item.name); // 祈祷item有name属性
    }
  }
  // 可能遗漏很多情况...
}

联合类型:编码时精确设计

// 编码时:✅ 预先定义所有可能性
type ApiData = 
  | { type: 'user'; name: string; age: number }
  | { type: 'product'; name: string; price: number }
  | { type: 'error'; message: string };

function processData(data: ApiData) {
  // 编码时:✅ 明确知道要处理的所有情况
  switch (data.type) {
    case 'user':
      return `${data.name} (${data.age}岁)`; // ✅ 精确访问
    case 'product':
      return `${data.name} - ¥${data.price}`; // ✅ 精确访问  
    case 'error':
      return `错误: ${data.message}`; // ✅ 精确访问
  }
}

为什么这很重要?

1. 思维方式的转变

从:"我该写什么代码来处理这个数据?" 变为:"这个数据可能是什么样子?"

2. 设计驱动的开发

// 先设计完整的状态机,再写处理逻辑
type OrderFlow = 
  | { stage: 'draft'; canEdit: true; saved: boolean }
  | { stage: 'submitted'; submittedAt: Date; canEdit: false }
  | { stage: 'paid'; paidAt: Date; amount: number }
  | { stage: 'shipped'; trackingNumber: string }
  | { stage: 'completed'; rating?: number };

// 现在写逻辑就是填空,不会遗漏
function handleOrder(order: OrderFlow) {
  switch (order.stage) {
    case 'draft':
      if (order.saved) { /* ... */ } // ✅ 精确知道可用属性
    case 'submitted':
      console.log(order.submittedAt); // ✅ 精确知道可用属性
    // ... 编译器确保处理所有情况
  }
}

3. 代码即设计文档

// 看类型就知道业务规则
type PaymentResult = 
  | { status: 'success'; transactionId: string; amount: number }
  | { status: 'failed'; reason: 'insufficient_funds' | 'network_error' | 'timeout' }
  | { status: 'pending'; estimatedCompletion: Date };

// 不用看代码实现,就知道:
// - 支付有3种可能结果
// - 失败有3种具体原因  
// - 每种结果有哪些相关信息

实际收益:从混乱到条理

Before:维护噩梦

function handleResponse(response) {
  // 新同事:这个response到底可能有什么字段?
  if (response.success) {
    if (response.data) {
      if (Array.isArray(response.data)) {
        // ... 嵌套越来越深
      }
    }
  } else if (response.error) {
    // 可能还有response.errorMessage?response.err?
  }
  // 6个月后:加新功能时完全不敢改这个函数
}

After:清晰条理

type Response = 
  | { success: true; data: User[] }
  | { success: false; error: string };

function handleResponse(response: Response) {
  if (response.success) {
    // ✅ 明确知道:这里response.data一定是User[]
    return response.data.map(user => user.name);
  } else {
    // ✅ 明确知道:这里response.error一定是string
    return `错误: ${response.error}`;
  }
  // 新同事一眼看懂所有可能性
  // 6个月后:安全地添加新功能
}

这才是真正的"工程化"

联合类型让我们从: "先写代码,再发现边界情况" 升级到: "先定义边界,再写处理逻辑"

这种预先设计的精确性,让代码:

  • 🎯 更有条理:逻辑结构清晰可见
  • 🔒 更安全:编译时捕获边界情况
  • 📚 更易维护:类型即文档
  • 🚀 更易扩展:添加新情况时编译器指导修改

这就是现代前端工程化的核心:用类型系统把不确定性消灭在编码阶段

总结

联合类型不是语法糖,而是编程思维的升级

  • 从模糊到精确:用类型表达真实的业务约束
  • 从脆弱到健壮:编译器帮你捕获边缘情况
  • 从复杂到简单:排除不可能的状态,简化逻辑

开始使用联合类型,让你的代码不再“说谎”,让编译器成为你最可靠的开发伙伴。


让不可能的状态变得不可能,这就是联合类型的真正威力。