11 类型放在哪里?interface、type、import type 与项目配置
核心原则:按语义和使用范围组织类型,不以“所有类型都集中”或“所有相似结构都复用”为目标。
1. 只有本模块使用:放在附近
Service 内部 Raw SQL 的统计行、某个 mapper 专用的 LoadedCustomer,一般留在 Service 附近。
优点是能同时看到查询、类型、映射;改字段时不必在多个目录之间寻找。
当前 customer.service.ts 的属性数组、Loaded 类型、DTO 和输入契约在一起,适合学习阶段理解完整流程。
2. 多模块共享:先判断契约是否稳定
当前 Controller 从 Service 导入输入/DTO 类型,关系很直接。模块增长后,可以选择独立文件:
某功能模块
customer.types.ts:公开输入、输出契约
customer.service.ts:业务实现
customer.controller.ts:HTTP 适配
这是组织思路,不要求本项目迁移目录。只拆两三个没有复用价值的类型,可能增加阅读成本。
跨模块真正通用的 PageResult、ApiResponse 放在现有 types/api.ts 合理;Customer 专有统计行不必塞进全局 types 文件。
3. 哪些“重复”其实是正确边界?
| 看起来相似的类型 | 是否应自动合并? |
|---|---|
| 原始 query 与验证后查询参数 | 不应:string 与 number/boolean 不同 |
| Model 与 DTO | 不应:实例能力、日期、状态、公开字段不同 |
| 创建输入与更新输入 | 可按业务关系用 Pick/Partial,但保留白名单 |
| 两个接口恰好都有 name/email | 先看语义,不因名字相同就绑定 |
| 多个分页列表的容器 | 通常可用 PageResult<T> 复用 |
重复定义本身不是问题,多个独立定义意外漂移才是问题。共享确实相同的来源,其他边界用 mapper 和测试连接。
4. interface 与 type 如何选?
两者都能描述对象,运行时都会消失。
interface CustomerInput {
first_name: string;
last_name: string;
}
type CustomerInputAlias = {
first_name: string;
last_name: string;
};
当前项目可沿用简单约定:
- 对象形状、DTO、参数契约:interface 或团队已有风格。
- 联合、工具类型结果、交叉、元组、派生类型:type。
type Status = "pending" | "active" | "banned";
type CustomerPatch = Partial<CustomerInput>;
interface 支持声明合并,适合扩展 Express 请求等库类型,但业务类型无意同名也可能被合并。type 同名重定义会报错。选择时重视意图与一致性,不必把它们当高低级语法。
5. interface extends 与交叉类型
interface NameFields { first_name: string }
interface CustomerDto extends NameFields { customer_id: number }
type NamedCustomer = NameFields & { customer_id: number };
都是组合属性。若同一个字段有冲突:interface extends 通常在组合位置报告不兼容;交叉可能得到不可满足的字段,例如 string & number 变 never。
需要改类型时先 Omit 再重新定义,或明确写独立 DTO,不要依赖交叉“覆盖”。
6. import type 表达“只读取类型”
import type { CustomerListItemDto } from "../../services/customer.service";
import type Customer from "../../models/customer";
只参与静态检查,不产生对应的运行时模块加载。
需要真正调用 ORM 类时:
import Customer from "../../models/customer";
const customer = await Customer.findByPk(1);
同一类名字可以在类型位置表示实例类型,在值位置作为构造器/静态 API 使用;是否需要普通 import,取决于有无运行时使用。
instanceof AppError 同样需要真实 AppError 类,不能仅 import type。
7. import type 与循环依赖
当前 Actor 只为关联属性声明引用 Film 类型,因此使用 import type Film;真正的关系注册在 associations.ts 用普通 imports 组织。
associations(运行时)→ Actor / Film → db
Actor 的 Film 类型引用:编译后消失
这样减少模型相互加载导致的运行时循环。仍然存在的值依赖要单独处理,不能认为增加 import type 会修复所有循环依赖。
一些仅用于类型的普通 import 在当前配置下也可能被编译器消除,但显式 import type 更准确表达意图,方便以后调整工具链。
8. 结合当前 tsconfig 理解报错
| 配置 | 当前状态 | 日常影响 |
|---|---|---|
strict |
true | 包括通常启用 strictNullChecks、noImplicitAny、unknown catch 等检查 |
noUncheckedIndexedAccess |
true | 普通数组/字典索引可能返回 undefined |
exactOptionalPropertyTypes |
true | 可省略属性不自动等于允许显式 undefined 写入 |
noImplicitReturns |
未启用 | 不额外启用全部隐式返回检查,但显式返回类型仍有约束 |
module |
commonjs | import/export 编译成 CommonJS 输出 |
verbatimModuleSyntax |
false | 当前允许模块转换,不原样保留所有导入语法 |
isolatedModules |
true | 提醒与逐文件转换相关的写法限制 |
skipLibCheck |
true | 跳过声明文件内部检查,不是关闭你源码的类型检查 |
更改配置影响整个项目,不应仅为某条红线随意修改。本轮教程不调整这些配置。
noUnusedLocals、noUnusedParameters 等不属于 strict 的全部自动内容,是否启用要看具体配置,而不是仅看 strict。
9. 书写 import 不等于真正运行 ESM
当前路径:
TS 源码写 import/export
→ TypeScript 按 commonjs 转换
→ ts-node 运行转换后的代码
因此增加 import type 不要求切换 package.json 的 type,也不要求加 ESM loader。
将来真正切 ESM 时,要一起评估 package.json、tsconfig 和启动方式,不单改一个文件。模块系统与类型复用是相关但不同的问题。
10. 什么时候写显式类型,什么时候让 TS 推断?
建议显式保留:公共 Service 输入/输出、DTO、外部配置契约、库结构如 Order、重要回调/断言契约。
局部常量、简单中间值、明显的 map 结果可以推断。写出大量 const count: number = 1 未必提升理解;省略所有公共返回类型也不利于契约稳定。
先让类型出现在真正有信息价值的地方。
11. 练习
- 列出 Customer Service 的类型,标记“模块私有/公开共享”。
- 找一个只用于类型的普通 import,判断能否用 import type 表达。
- 给两个边界都叫 email 的类型说明差异,而不是直接合并。
- 根据 tsconfig 解释数组取首项、可选字段赋 undefined 的报错。
下一章:常见报错与速查。