Meta Description:本文从大型前端项目角度,分析 TypeScript 类型系统失控的常见原因,并总结 interface、type、unknown、泛型、DTO、领域模型、运行时校验等工程实践。
Slug:typescript-large-project-type-design
前言
很多团队使用 TypeScript 的初衷很简单:
减少 Bug。
提升代码提示。
让项目更可靠。
但实际情况却经常相反。
项目刚开始时,TypeScript 确实很好用。
写接口有提示。
改字段能报错。
重构更安心。
但是随着项目变大,类型开始越来越复杂:
到处都是 any
类型重复定义
接口返回类型和页面类型混在一起
泛型层层嵌套
工具类型无限组合
类型报错没人看得懂
最后团队会发现:
TypeScript 不但没有降低复杂度,反而变成了新的负担。
这不是 TypeScript 的问题,而是类型系统缺少设计。
大型项目中的 TypeScript,不能只靠随手定义类型,而需要像设计代码架构一样设计类型架构。
any 是类型系统失控的开始
any 最大的问题不是它本身,而是它会扩散。
一旦某个接口返回 any,后续所有使用它的地方都会失去类型保护。
例如:
接口返回 any。
组件接收 any。
表单数据 any。
最终整个链路都变成 any。
这时 TypeScript 只是表面存在,实际上已经退化成 JavaScript。
在大型项目中,any 应该被视为临时方案,而不是常规写法。
如果必须使用 any,应该明确原因,并尽量限制范围。
例如:
第三方库类型缺失。
历史代码迁移。
动态结构暂时无法确定。
除此之外,优先使用 unknown。
unknown 比 any 更适合不确定数据
unknown 表示:
我不知道它是什么类型。
但在使用之前,必须先判断。
这比 any 安全很多。
例如接口返回的数据不确定,可以先用 unknown 接收,然后通过类型判断或运行时校验转换成可靠类型。
unknown 的价值在于:
它承认不确定性,但不放弃类型安全。
对于大型项目来说,这比 any 更适合作为边界类型。
尤其是:
外部接口
本地缓存
用户输入
第三方 SDK
这些数据都不应该被盲目信任。
区分接口返回类型和业务模型
很多项目会直接把后端接口返回类型拿来当页面业务类型。
例如接口返回:
user_name
avatar_url
created_at
前端页面也直接使用这些字段。
短期没问题。
但长期会导致前端严重依赖后端字段结构。
一旦后端字段变化,整个页面都会受影响。
更合理的做法是区分:
DTO
Domain Model
View Model
DTO 表示接口返回数据。
Domain Model 表示前端业务模型。
View Model 表示页面展示模型。
例如后端返回 user_name,前端业务中可以转换成 userName。
这样后端格式变化时,只需要修改转换层,而不是全项目搜索替换。
类型转换层非常重要
大型项目中,API 层不应该只是请求接口。
它还应该负责把不稳定的外部数据转换成稳定的内部模型。
推荐流程:
后端接口
DTO
转换函数
Domain Model
页面使用
这样做的好处是:
页面不依赖后端字段命名。
接口变更影响范围更小。
类型更加稳定。
测试更容易。
很多项目类型混乱,就是因为缺少这层转换。
结果是后端结构、前端状态、页面展示全部混在一起。
interface 和 type 如何选择?
interface 和 type 都能描述对象。
很多团队最大的问题不是选错,而是没有统一规范。
建议:
普通对象结构使用 interface。
联合类型、工具类型、函数类型使用 type。
例如:
User、Product、Order 这种实体对象,可以用 interface。
Status、Theme、Nullable、ApiResult 这种类型组合,可以用 type。
保持一致比争论哪一个更好更重要。
大型项目最怕的是同一种类型一会儿 interface,一会儿 type,维护者需要不断猜测团队规则。
enum 是否还值得使用?
在现代前端项目中,enum 的使用需要谨慎。
TypeScript enum 会生成运行时代码。
在一些场景下,这不是问题。
但如果只是定义几个固定字符串,很多团队更倾向于使用 as const 对象。
例如:
STATUS = { SUCCESS: ‘success’, FAIL: ‘fail’ } as const
这种方式更接近 JavaScript 本身,也更利于 Tree Shaking。
不过并不是说 enum 完全不能用。
如果团队规范明确,并且需要双向映射,enum 仍然有使用价值。
关键是不要在项目中混用多种风格。
泛型不是越多越高级
泛型是 TypeScript 最强大的能力之一,也是最容易被滥用的能力之一。
很多开发者写类型时喜欢追求“通用”。
结果一个简单函数写出三四层泛型。
短期看起来很灵活,长期非常难维护。
泛型应该服务于复用,而不是炫技。
如果一个泛型只有一个地方使用,且普通类型就能表达清楚,就没有必要强行抽象。
大型项目中,类型可读性往往比类型技巧更重要。
工具类型不要无限嵌套
TypeScript 提供了很多工具类型:
Partial
Pick
Omit
Record
Required
Readonly
ReturnType
Parameters
这些都非常实用。
但如果出现:
Omit<Pick<Partial<User>, ‘name’ | ’email’>, ’email’>
这种类型,阅读成本就很高。
建议:
复杂类型要拆分命名。
不要在业务代码中写过长的类型表达式。
类型也是代码,也需要可读性。
当一个类型表达式需要别人反复阅读才能理解时,它就应该被重构。
表单类型如何设计?
表单是 TypeScript 项目中的高频场景。
很多团队直接复用后端 DTO 作为表单类型。
这通常不合适。
因为表单状态和接口数据不同。
例如:
后端 userId 是 number。
表单中可能是 string。
后端字段必填。
表单初始化时可能为空。
后端不需要 confirmPassword。
但注册表单需要。
因此表单应该有独立类型。
例如:
LoginForm
RegisterForm
UserEditForm
不要强行复用接口类型。
复用过度会让类型越来越扭曲。
API 返回类型如何统一?
大型项目应该统一 API 返回结构。
例如:
ApiResponse<T>
PaginationResult<T>
ListResult<T>
这样可以减少重复定义。
但也要注意不要过度封装。
一个常见设计是:
ApiResponse<T> 表示普通接口。
PageResult<T> 表示分页接口。
ErrorResponse 表示错误结构。
所有 API 都基于这些基础类型组合。
这样既统一,又不会过度复杂。
类型系统不能替代运行时校验
这是非常重要的一点。
TypeScript 只在编译阶段存在。
代码运行后,类型已经不存在。
也就是说:
接口返回的数据可能不符合类型。
用户输入可能不符合类型。
本地缓存可能是旧结构。
第三方 SDK 可能返回异常数据。
因此,关键数据必须做运行时校验。
常见方案包括:
Zod
Valibot
ArkType
例如登录接口、支付接口、权限配置、用户输入,都不应该只依赖 TypeScript 类型。
类型负责开发期安全。
运行时校验负责生产环境安全。
两者不能互相替代。
类型文件应该如何组织?
很多项目会有一个 types 目录,里面放所有类型。
项目小时可以。
项目大了会很混乱。
更推荐按业务模块组织类型。
例如:
features/user/types
features/order/types
features/product/types
只有全局通用类型才放 shared/types。
例如:
ApiResponse
Pagination
Nullable
Option
这样可以避免所有类型都堆在一个文件夹中。
类型也应该遵循 Feature First。
命名规范非常重要
类型命名应该清晰表达用途。
例如:
UserDTO
UserModel
UserForm
UserView
UserOption
不要所有类型都叫 User。
否则后期很难判断它到底来自接口、业务层还是页面层。
命名清晰,可以减少大量沟通成本。
尤其在多人协作项目中,类型命名就是文档的一部分。
如何避免类型系统过度设计?
TypeScript 的目标不是写出最复杂的类型,而是让项目更可靠、更容易维护。
如果为了类型正确,导致业务代码难以阅读,那就本末倒置。
建议遵循几个原则:
不要为了未来可能出现的场景提前设计复杂泛型。
不要为了消灭所有重复写出难懂类型。
不要在业务代码里塞过长类型表达式。
不要让类型系统变成少数人才看得懂的“黑魔法”。
一个好的类型系统,应该让普通团队成员也能轻松理解和维护。
总结
TypeScript 在大型项目中的价值,不只是提供类型提示。
它更重要的作用是建立稳定的数据边界。
要避免类型系统失控,需要从几个方面入手:
减少 any。
优先使用 unknown 处理不确定数据。
区分 DTO、Domain Model 和 View Model。
建立类型转换层。
统一 API 返回类型。
避免泛型和工具类型滥用。
关键数据增加运行时校验。
按业务模块组织类型。
保持命名清晰。
TypeScript 不是为了让代码看起来高级,而是为了让项目在长期迭代中更安全、更可维护。
真正优秀的类型设计,应该是清晰、稳定、适度,而不是复杂。
X记录空间