欢迎光临
我们一直在努力

TypeScript 大型项目类型设计:如何避免类型系统失控

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 不是为了让代码看起来高级,而是为了让项目在长期迭代中更安全、更可维护。

真正优秀的类型设计,应该是清晰、稳定、适度,而不是复杂。

赞(0)
未经允许不得转载:X记录空间 » TypeScript 大型项目类型设计:如何避免类型系统失控