12k
All articles

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

在 TypeScript 项目中有效组织类型定义,通过明确的同置策略,覆盖内联文件、共享目录与 ambient 声明文件三类场景。

OpenReplay Team
OpenReplay Team
如何在 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 类型的目标不是完美的文件夹结构——而是让下一个开发者(或未来的你)能够毫无阻碍地查找和重用类型。

常见问题

我应该把所有 TypeScript 类型放在一个 types.ts 文件中吗?

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

.ts 和 .d.ts 文件用于类型定义有什么区别?

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

我应该如何命名 TypeScript 类型文件?

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

如何在 monorepo 中从共享包暴露类型?

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

Open-source session replay

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.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.