29、JavaScript/TypeScript中的重构:动态类型的重构技巧、类型系统的利用

说实话,JavaScript 这门语言很有意思。它灵活到让你飞起来,也灵活到让你摔得很惨。我在团队里带过不少项目,每次看到那种「跑起来没问题,但改起来想哭」的 JS 代码,就知道——该重构了。

而 TypeScript 的出现,说白了就是给这匹野马套上了缰绳。但别误会,光有类型系统还不够,关键是怎么用它来指导重构。今天我就聊聊这些年积累的一些实战经验。

动态类型下的重构陷阱

先说说纯 JS 环境下的重构。你想想看,一个函数参数可以是字符串、数字、甚至对象——这种灵活性在小型项目里很爽,但项目一膨胀,问题就来了。

典型场景:你接手一个老项目,有个函数叫 processData(data),你根本不知道 data 到底长什么样。改一行代码,可能炸了三个模块。

我曾经在一个电商后台遇到过这种事。一个订单处理函数,参数有时候是对象,有时候是数组,有时候干脆是 null。调用方有十几个,每个都按自己的理解传参。重构?那简直是拆弹。

那怎么办?我的习惯是:先写测试,再动手改。哪怕没有单元测试,也要先写几个关键的集成测试,把核心路径锁住。

利用 TypeScript 类型系统指导重构

TypeScript 的好处,不只是「能检查类型错误」。它更像是一份活的文档,告诉你「这个函数应该怎么用」。

我建议你在重构前,先做一件事:把 any 类型逐步替换成具体类型。这个过程本身就是在梳理代码结构。

// 重构前
function calculatePrice(items: any): any {
  return items.reduce((sum: any, item: any) => sum + item.price, 0);
}

// 重构后
interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

function calculatePrice(items: CartItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

你看,类型一明确,逻辑也跟着清晰了。原来 price 是单价,现在乘上 quantity 才是总价——这个 bug 在类型化之前根本发现不了。

类型守卫与可辨识联合

动态类型最头疼的问题之一:一个变量可能是多种类型。这时候,可辨识联合(Discriminated Union) 就是你的救星。

核心思路:用一个公共字段来区分不同的类型分支,然后通过类型守卫缩小类型范围。
// 重构前:到处是 if-else 和类型判断
function handleEvent(event: any) {
  if (event.type === 'click') {
    console.log(event.x, event.y);
  } else if (event.type === 'keypress') {
    console.log(event.key);
  }
}

// 重构后:可辨识联合
type AppEvent =
  | { type: 'click'; x: number; y: number }
  | { type: 'keypress'; key: string }
  | { type: 'resize'; width: number; height: number };

function handleEvent(event: AppEvent) {
  switch (event.type) {
    case 'click':
      console.log(event.x, event.y);
      break;
    case 'keypress':
      console.log(event.key);
      break;
    case 'resize':
      console.log(event.width, event.height);
      break;
  }
}

嗯,这里要注意:switch 里的每个 case,TypeScript 会自动帮你缩小类型。如果你漏掉某个分支,编译器会报错——这就是类型系统在帮你做「穷举检查」。

泛型:让重构更安全

泛型这东西,很多人觉得是「高级用法」,其实不然。它本质上就是让类型跟着逻辑走

我举个例子。你写了一个缓存工具,原来用 any 实现:

// 重构前:类型不安全
class Cache {
  private store = new Map<string, any>();
  
  get(key: string): any {
    return this.store.get(key);
  }
  
  set(key: string, value: any): void {
    this.store.set(key, value);
  }
}

用泛型重构后:

// 重构后:类型安全
class Cache<T> {
  private store = new Map<string, T>();
  
  get(key: string): T | undefined {
    return this.store.get(key);
  }
  
  set(key: string, value: T): void {
    this.store.set(key, value);
  }
}

// 使用
const userCache = new Cache<User>();
userCache.set('user1', { name: 'Alice' }); // 类型正确
userCache.set('user1', 123); // 编译错误!

你看,泛型把「运行时才能发现的错误」提前到了「编译时」。这省了多少调试时间啊。

重构中的类型演进策略

如果你面对的是一个大型 JS 项目,不可能一夜之间全部改成 TS。我建议分步走:

  1. 先加 // @ts-check 注释,让编辑器帮你做基础检查
  2. 把 .js 改成 .ts,但先不修类型错误,用 any 占位
  3. 从底层模块开始,逐步替换 any 为具体类型
  4. 开启 strict 模式,强迫自己处理边界情况
小技巧:我习惯用 unknown 代替 any。unknown 更安全,因为它强制你在使用前做类型检查。说白了,any 是「放弃治疗」,unknown 是「先确认再动手」。

动态类型重构的避坑指南

我曾经犯过一个错:重构一个工具函数时,把参数类型收窄了,结果调用方传了 undefined 进来,线上直接报错。从那以后,我养成了一个习惯——先看调用方,再改类型

这里列几个我踩过的坑:

  • 别轻易用 ! 非空断言——它只是让编译器闭嘴,运行时该炸还是炸
  • 小心 Object.keys() 的返回值——它返回的是 string[],不是具体的 key 类型
  • 条件类型别嵌套太深——可读性会急剧下降,团队其他人看不懂

知识体系总览

下面这张图,是我对本章内容的一个梳理。你可以把它当作重构时的「检查清单」:

JS/TS 重构核心知识体系 重构目标 动态类型技巧 先写测试再改 逐步替换 any 关注调用方 类型系统利用 可辨识联合 泛型约束 类型守卫 演进策略 分步迁移 底层优先 strict 模式 避坑指南 慎用非空断言 Object.keys 陷阱 条件类型别嵌套 核心原则 类型先行 安全第一 渐进改进

总结一下

JavaScript/TypeScript 的重构,核心就一句话:用类型系统把「运行时错误」变成「编译时错误」。动态类型给了你灵活性,类型系统给了你安全感——两者结合,才是正道。

我个人习惯是:新项目直接上 TypeScript 加 strict 模式;老项目先加类型声明,再逐步替换。别想着一步到位,重构是个持续的过程。

嗯,最后提醒一句:类型系统是工具,不是目的。别为了「类型完美」而写出难以理解的代码。好的重构,是让代码更容易理解、更安全、更容易修改——而不是让类型定义比业务逻辑还长。


公众号:蓝海资料掘金营,微信 deep3321