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;
};

当前项目可沿用简单约定:

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. 练习

  1. 列出 Customer Service 的类型,标记“模块私有/公开共享”。
  2. 找一个只用于类型的普通 import,判断能否用 import type 表达。
  3. 给两个边界都叫 email 的类型说明差异,而不是直接合并。
  4. 根据 tsconfig 解释数组取首项、可选字段赋 undefined 的报错。

下一章:常见报错与速查。