TypeScript用了好几年了,从最早的2.x版本一直用到现在的5.4。

这几年里,TypeScript给我带来了很多好处:类型安全、更好的IDE支持、更容易重构。但同时,我也踩了不少坑。有些坑是TypeScript本身的设计问题,有些是我自己用得不对,还有些是社区最佳实践还没形成的时候走的弯路。

这篇文章,我想分享一下这些年用TypeScript踩过的坑,以及对应的解决方案。希望能帮大家少走一些弯路。

坑一:any用得太多

这应该是所有TypeScript初学者都会踩的坑。

刚开始用TypeScript的时候,觉得类型系统太麻烦了。这个参数要加类型,那个返回值要加类型,写起来比JavaScript慢多了。于是就到处用any,一行代码里好几个any。

结果就是,TypeScript变成了"AnyScript"。类型检查形同虚设,该出的bug还是会出,而且出了问题更难排查,因为你以为有类型保护,其实没有。

我后来的解决办法是,在tsconfig里开启noImplicitAny和strict模式。这样,任何隐式的any都会报错,逼着你去写正确的类型。

刚开始的时候会很痛苦,觉得怎么写都不对。但坚持一段时间之后,你会发现自己对类型系统的理解越来越深,写出来的代码质量也越来越高。

还有一个技巧是,用unknown代替any。unknown和any类似,可以接受任何类型的值,但unknown在使用之前必须做类型检查。这样既保留了灵活性,又不会完全失去类型安全。

坑二:类型断言滥用

类型断言(as)是TypeScript里一个很强大的功能,但也很容易被滥用。

很多时候,TypeScript的类型推断不符合你的预期,你就会想用as来强制转换。比如,你从后端拿到一个数据,TypeScript推断的类型是any或者unknown,你就直接as成你想要的类型。

但问题是,类型断言只是告诉编译器"相信我,我知道我在做什么",它并不会在运行时做任何检查。如果实际的数据类型和你断言的不一样,运行时还是会出bug。

我踩过最严重的一次坑是,后端接口返回的数据结构变了,但前端还是用旧的类型断言。结果,代码编译通过了,但运行的时候全是undefined,排查了半天才发现是数据结构变了。

后来我的做法是,对于外部数据(比如API返回、用户输入、localStorage读取),不用类型断言,而是用类型守卫(type guard)或者schema校验库(比如zod、yup)来做运行时验证。这样既能保证类型安全,又能在运行时捕获错误。

比如,用zod的话,可以这样写:

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

function parseUser(data: unknown): User {
  return UserSchema.parse(data);
}

这样,parseUser函数会在运行时验证数据,如果不符合schema就会抛出明确的错误,而不是默默地返回错误的数据。

坑三:泛型用得太复杂

泛型是TypeScript最强大的功能之一,但也是最容易被滥用的。

我见过很多代码,为了"类型安全",把泛型写得极其复杂。一个函数的类型声明有十几行,各种条件类型、映射类型、模板字面量类型嵌套在一起,看都看不懂。

我自己也踩过这个坑。有一次,为了写一个类型安全的事件系统,我用了大量的条件类型和映射类型,写了将近一百行类型代码。结果是,类型推导很慢,IDE经常卡顿,而且除了我之外没人能看懂这些类型。

后来我重构了,用了更简单的类型定义,虽然类型安全稍微弱了一点,但代码的可维护性大大提升了。

我的经验是,泛型和高级类型应该用在真正需要的地方。如果一个类型定义需要花十分钟才能看懂,那它可能太复杂了。这时候,不如用更简单的类型,或者用一些any/unknown来降低复杂度。

记住,TypeScript的目标是帮助你写出更好的代码,而不是让你在类型系统里炫技。

坑四:tsconfig配置不当

tsconfig.json的配置,很多人不重视,用默认的或者随便抄一个。但实际上,tsconfig的配置对开发体验和代码质量影响很大。

我踩过的第一个配置坑是,没有开启strict模式。strict模式包含了很多严格的类型检查,比如noImplicitAny、strictNullChecks、strictFunctionTypes等。不开启这些,类型检查会宽松很多,很多潜在的bug发现不了。

第二个坑是,target设置太高或者太低。target设置太高(比如ES2022),在旧浏览器里会出问题;设置太低(比如ES5),又会导致很多新特性无法使用,编译出来的代码体积也大。

我的建议是,根据目标运行环境来设置target。如果是现代浏览器,可以设为ES2020或更高;如果要兼容旧浏览器,可以设为ES2017,然后用Babel做进一步的转译。

第三个坑是,没有配置path别名。项目大了之后,文件之间的引用会变得很深,比如import { foo } from '../../../components/foo'。这种相对路径很难看,也容易出错。

在tsconfig里配置path别名,可以解决这个问题:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"]
    }
  }
}

这样就可以写成import { foo } from '@components/foo'。不过要注意,path别名只是TypeScript层面的,打包工具(比如webpack、vite)也需要做对应的配置。

第四个坑是,没有配置类型检查的排除项。如果把node_modules或者dist目录也包含在类型检查里,会导致编译很慢,甚至出现一些莫名其妙的类型错误。应该在tsconfig里用exclude把这些目录排除掉。

坑五:第三方库的类型问题

用第三方库的时候,经常会遇到类型问题。

第一种情况是,库本身没有类型定义。这时候,你需要安装@types/xxx包。比如,lodash本身没有类型,需要安装@types/lodash。

第二种情况是,@types包的版本和库的版本不匹配。这会导致类型定义和实际API不一致,要么报错,要么类型不准确。我的做法是,安装@types包的时候,尽量安装和库版本对应的大版本。

第三种情况是,类型定义有错误或者不完整。这时候,你可以用declaration merging来扩展类型定义。比如,给一个库的类型添加新的方法:

declare module 'some-lib' {
  interface SomeClass {
    newMethod(): void;
  }
}

第四种情况是,库的类型定义太宽松,全是any。这时候,与其依赖库的类型,不如自己定义更严格的类型,在调用库的地方做一层封装。

还有一个常见的问题是,不同库的类型定义之间有冲突。比如,两个库都定义了全局的某个类型,导致冲突。这时候,可以用skipLibCheck来跳过对.d.ts文件的检查,虽然不能解决根本问题,但至少能让编译通过。

坑六:React里的类型问题

我主要用React开发,在React + TypeScript里也踩了不少坑。

第一个坑是,props的类型定义。刚开始的时候,我会给每个组件写一个interface来定义props类型。但后来发现,很多props是可以复用的,比如className、style、children这些。React已经提供了很多内置的类型,比如React.PropsWithChildren、React.HTMLAttributes等,可以直接用。

比如,一个按钮组件的props可以这样定义:

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary';
  loading?: boolean;
}

这样就自动继承了所有原生button的属性,不需要自己一个个写。

第二个坑是,useState的类型推断。useState如果有初始值,TypeScript会自动推断类型。但如果初始值是null或者undefined,就需要显式指定类型:

const [user, setUser] = useState<User | null>(null);

第三个坑是,事件处理函数的类型。很多人不知道事件对象的类型怎么写,就用any。其实React提供了各种事件类型,比如React.MouseEvent、React.ChangeEvent、React.FormEvent等。

function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
  // ...
}

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  // ...
}

第四个坑是,useRef的类型。useRef有两种用法,一种是用来引用DOM元素,一种是用来保存可变值。类型定义不一样:

// 引用DOM元素
const inputRef = useRef<HTMLInputElement>(null);

// 保存可变值
const countRef = useRef<number>(0);

坑七:类型收窄的问题

TypeScript的类型收窄(type narrowing)是一个很强大的功能,但有时候也会让人困惑。

比如,你用if判断了一个值不是null,TypeScript应该能收窄类型。但如果在if里面调用了一个函数,TypeScript可能就无法收窄了,因为它不知道那个函数会不会改变这个值。

function foo(user: User | null) {
  if (user !== null) {
    console.log(user.name); // OK,user被收窄为User
    doSomething(); // 调用了一个函数
    console.log(user.name); // 这里user又变成了User | null
  }
}

这是因为TypeScript不知道doSomething会不会把user改成null。解决方法是,把user赋值给一个局部变量,或者在使用之前再检查一次。

还有一个常见的问题是,在回调函数里类型收窄失效。比如:

function foo(user: User | null) {
  if (user !== null) {
    setTimeout(() => {
      console.log(user.name); // 报错,user可能是null
    }, 1000);
  }
}

这是因为回调函数可能在user被修改之后才执行。解决方法是,在if里面把user赋值给一个const变量,然后在回调里用这个变量。

写在最后

TypeScript是一个非常优秀的工具,但它不是万能的。它能帮你发现很多潜在的问题,但前提是你要用对它。

这些年踩过的坑,让我总结出几个原则:

第一,尽量不用any,用unknown代替。 第二,对外部数据做运行时验证,不要只靠类型断言。 第三,类型定义要简洁可读,不要为了类型安全而牺牲可维护性。 第四,配置好tsconfig,开启严格模式。 第五,善用第三方库的类型,遇到问题学会扩展和封装。

TypeScript一直在发展,每个新版本都会带来新的功能和改进。5.4版本带来了更好的类型收窄、新的工具类型等。保持学习,跟上版本的步伐,才能更好地使用这个工具。

希望这篇文章能帮你避开一些坑。如果你也有踩坑的经历,欢迎在评论区分享。