Back

如何在 TypeScript 项目中组织类型定义

如何在 TypeScript 项目中组织类型定义

大多数 TypeScript 项目都以相同的方式开始:一个单独的 types.ts 文件,然后慢慢膨胀到数百行。查找任何内容都变成了一件苦差事。跨文件重用类型感觉很冒险。新团队成员不知道该去哪里查找。

好消息是,组织 TypeScript 类型不需要复杂的系统。它只需要一个清晰的规则:将类型放置在尽可能靠近使用它们的地方,只有当它们在其他地方需要时才移动它们。

以下是如何在项目的每个阶段应用该规则。

核心要点

  • 将类型尽可能放置在靠近其使用位置的地方,只有当多个文件需要它们时才将它们提升到共享位置。
  • 使用 .ts 文件进行显式的类型导出和导入;将 .d.ts 文件严格保留用于环境声明,如环境变量或全局扩充。
  • 采用一致的命名约定,例如使用 *.types.ts 表示模块特定的类型,使用桶文件 index.ts 来实现清晰、稳定的导入路径。
  • 在 monorepo 或共享包中,通过 package.json 导出中的 types 条件来暴露类型,并保持内部类型私有。

核心决策:类型应该放在哪里?

问自己一个问题:有多少文件需要这个类型?

  • 一个文件 → 在该文件中内联定义
  • 同一模块中的几个文件 → 将其放在附近的共享 .types.ts 文件中
  • 整个项目 → 将其移动到共享的 src/types/ 目录
  • 跨包 → 从专用的类型包中发布

这种渐进式方法可以保持项目的类型定义井然有序,而不会从一开始就过度设计。

模式 1:内联类型用于本地使用

如果一个类型只在一个文件中使用,就在那里定义它。不需要单独的文件。

// UserCard.tsx
interface UserCardProps {
  name: string
  avatarUrl: string
  role: "admin" | "viewer"
}

export function UserCard({ name, avatarUrl, role }: UserCardProps) {
  // ...
}

这是正确的默认做法。过早的抽象会产生间接性而没有好处。

模式 2:共享模块类型的同位类型文件

当同一文件夹中的两个或多个组件共享类型时,将它们提取到同位的 .types.ts 文件中。

src/components/user/
├── UserCard.tsx
├── UserList.tsx
└── user.types.ts       ← 此模块的共享类型

这使相关类型保持在使用它们的代码附近,而不会污染全局文件。

模式 3:用于横切定义的共享 types/ 目录

当类型在多个不相关的模块中使用时,集中的 src/types/ 目录是有意义的。使用桶文件 index.ts 来保持导入的清晰和稳定。

src/types/
├── index.ts            ← 重新导出所有内容
├── api.types.ts
├── user.types.ts
└── product.types.ts
// src/types/index.ts
export type { ApiResponse, PaginatedResult } from "./api.types"
export type { User, UserRole } from "./user.types"

使用者从一个路径导入:import type { User } from "@/types"。如果稍后重新组织内部结构,导入路径不会改变。

应该使用 .ts 还是 .d.ts 来定义类型?

这是 TypeScript 类型组织中最常见的困惑点之一。

使用 .ts 文件用于显式导出和导入的类型。这是应用程序中几乎所有内容的正确选择。

使用 .d.ts 文件主要用于环境声明——当你需要告诉 TypeScript 某些在运行时存在但没有 TypeScript 源代码的内容时。常见示例:

  • env.d.ts — 为 Vite 或类似打包工具声明 import.meta.env 变量
  • global.d.ts — 扩充第三方模块类型或声明全局变量
// env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
  readonly VITE_API_URL: string
}

一些现代项目显式配置 compilerOptions.types 来控制加载哪些环境类型,而其他项目则依赖自动的 @types 发现。如果你使用基于打包工具的工具链,如 Vite 或 Next.js,请遵循其推荐的这些文件约定,而不是创建独立的全局声明。

可扩展的命名约定

一致的命名可以减少团队的认知负担:

模式示例使用场景
*.types.tsuser.types.ts模块特定的类型
index.tstypes/index.ts桶文件重新导出
env.d.tsenv.d.ts环境变量声明
global.d.tsglobal.d.ts第三方类型扩充

避免使用 IUser 前缀和 UserType 后缀。像 UserApiResponse 这样的简单名称更清晰,并且符合现代 TypeScript 约定。

在共享包中组织 TypeScript 类型

如果你正在构建一个库或在 monorepo 中工作,通过 package.json 导出使用 types 条件来暴露类型,而不是依赖旧的 typesVersions 模式。

{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}

保持内部类型私有——不要从包入口点导出它们。只有公共 API 表面应该对使用者可见。

结论

从内联类型开始。当第二个文件需要它们时,提取到同位文件。当它们真正横切时,提升到 src/types/。仅将 .d.ts 文件用于环境声明,而不是作为所有类型定义的默认位置。

组织 TypeScript 类型的目标不是完美的文件夹结构——而是让下一个开发者(或未来的你)能够毫无阻碍地查找和重用类型。

常见问题

不应该。单个文件在早期可以工作,但随着项目的增长会变得难以导航。相反,将类型保持在使用它们的地方附近。对于单文件使用,内联定义它们;当在模块内共享时,提取到同位的 .types.ts 文件;只有当它们在代码库的不相关部分需要时,才提升到中央类型目录。

使用 .ts 文件用于在应用程序代码中显式导出和导入的类型。仅将 .d.ts 文件用于环境声明,例如描述环境变量或扩充第三方模块类型。将常规应用程序类型放在 .d.ts 文件中可能会引起混淆,因为这些声明在没有显式导入的情况下是全局可用的。

一个被广泛采用的约定是对模块特定的类型使用 module-name.types.ts 模式,并在共享类型目录中使用 index.ts 桶文件进行重新导出。避免使用匈牙利命名法,如 IUser 或冗余后缀,如 UserType。在现代 TypeScript 项目中,首选像 User 和 ApiResponse 这样简单、描述性的名称。

在 package.json 的 exports 字段中使用 types 条件来指向你编译的声明文件。这种方法比旧的 typesVersions 模式更明确和可靠。只导出构成公共 API 一部分的类型,并保持内部类型私有,以避免向使用者泄露实现细节。

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay