- Published on
类型安全是一条跨层的链:strict 模式只守住了链的内部
- Authors

- Name
- Jack Qin
很多人对类型安全的理解停在"开了 strict 模式"。但 strict 模式守住的只是一层内部的自洽——它能保证你在这个文件里不会把 string 当 number 用,却管不住数据从数据库流到组件这一路上、每个边界处的承诺有没有兑现。
而 bug 恰恰不在层内部,它在边界冒出来。类型安全真正的价值,是把"我返回的形状"和"你假设的形状"这两件事在编译期对齐——它是一条从数据库一直贯通到组件边界的链条,任何一环松动,bug 都会在那一环的交界处出现。这篇想拆的,就是这条链在前端和后端两侧分别怎么不断。
为什么类型要跨层谈
类型最大的价值不在于本层内部的自洽,而在于跨层边界的承诺能不能兑现。一个只在本层严格、在边界处松手的类型系统,等于在最容易出错的地方放弃了保护。
前端最常见的破坏方式是:某个 Hook 直接把后端原始 DTO 透传出去,或者干脆返回 any,理由是"反正能跑"。这一手的代价是隐形的——消费方完全不知道数据长什么样,而更糟的是,当后端改了某个字段名,没有任何编译期信号去提示"前端这里会断"。类型本该在这种跨层改动时亮红灯,any 把灯关了。
后端的破坏方式更结构化:一个模块为了图方便,直接引用了另一个模块的实现项目(而不是它的 Contracts 项目),或者把 EF 实体直接从 endpoint 返回出去。前者打穿了模块边界,后者把持久化细节泄漏给了所有消费者——前端、生成的客户端,全都被迫认识了数据库的内部结构。
所以类型安全必须前后端一起谈:前端靠类型化 API、类型化映射、类型化 Hook,把链条在客户端这一侧接好;后端靠显式契约、强模块边界、编译期与架构测试的双重约束,把链条在服务端这一侧接好。
前端:strict 模式 + 功能本地类型 + 类型化全链路
仓库规则:
apps/web/tsconfig.json开启strict模式。@/*解析到src/*。- 功能本地类型就近放在拥有它的功能里。
- 优先用类型化的 API 响应和类型化 Hook,而不是无类型的转换链。
- 测试文件的 lint 比应用代码宽松,但应用代码仍应避免
any。
类型组织:共享的应用级类型放在拥有它的库附近,功能本地类型放在功能里。比如 email-schedules 的功能类型放在它自己的 types 目录,DataTable 的泛型则放在共享组件里。用就近的类型,而不是堆一个巨型全局类型桶——巨型类型桶会让"这个类型归谁、改它会影响谁"变得无法回答。
验证靠的不是运行时 schema,而是类型化全链路:当前前端的类型安全主要不是靠某个运行时 schema 库,而是来自一条端到端类型化的链——类型化的 API 客户端使用、类型化的 DTO 到模型映射、类型化的 query Hook,以及后端契约生成 + 漂移检查。例如周报 Hook 在暴露数据前会先把 DTO 映射成模型;CI 里的契约工作流会强制检查 API schema 与客户端的漂移。这条链的每一环都在做同一件事:让数据穿过一个边界时,类型跟着穿过去,而不是在边界处被 any 抹平。
已强制的 ESLint 规则(这些不是建议,是规则):
@typescript-eslint/consistent-type-imports@typescript-eslint/consistent-type-exports@typescript-eslint/no-confusing-void-expression@typescript-eslint/no-import-type-side-effects
常见模式:尽量用 type 导入导出;局部返回类型让 TS 推断,除非显式标注更清晰;可复用共享组件用泛型;用类型化 query key、类型化 API 客户端、类型化 Hook 结果。
后端:契约是项目,不是文件夹
后端靠显式契约、强模块边界和编译期约束来保证类型安全。这里有一个关键的措辞值得停一下:契约是项目(project),不是文件夹(folder)。
每个模块通过专门的 *.Contracts 项目暴露它的公开数据形状,例如 AssetDto、DustLevelDto、EmailKind。为什么是独立项目而不是一个 Contracts/ 文件夹?因为项目边界是编译器和架构测试能强制的,而文件夹边界只是组织上的建议。把契约放进独立项目,"其它模块只能引用 Contracts、不能引用实现"这条约束才有了可执行的抓手——一个错误的实现引用会变成一个编译期/架构测试可见的违规,而不是一个埋在文件夹里、谁都能绕过的约定。
规则:
- 其它模块只能引用某模块的 Contracts 项目,不能引用它的内部实现。
- Contracts 必须不含 EF Core 和 host 级依赖。
- 不要把持久化实体直接通过 API endpoint 或事件泄漏出去。
这些规则不只靠人盯,架构测试会强制其中一部分。
Endpoint 要薄。Endpoint 只负责:路由映射、认证与权限检查、请求模型到领域/应用模型的转换、返回 HTTP 结果。不要把业务规则、跨模块编排或可复用的持久化逻辑搬进 endpoint 类——除非该模块本来就这么做,且这段逻辑确实是路由本地的。
共享抽象只放 transport/host 无关的东西。Platform.BuildingBlocks 只用于传输无关、host 无关的共享抽象,比如 Result.cs、ICommandDispatcher、IEventPublisher。架构测试强制了几条铁律:
BuildingBlocks不能依赖 API hostBuildingBlocks不能依赖 Worker hostBuildingBlocks不能直接依赖 MassTransit 或 EF Core
这几条的共同逻辑是:共享抽象一旦依赖了某个具体基础设施,它就把那个基础设施的"重量"传染给了所有引用它的模块。BuildingBlocks 越纯净,它能被安全复用的范围就越大。
可空性与显式性:遵循代码库已隐含的 C# 显式实践——当 API 真的允许省略时,把可选输入和筛选参数设为可空;让 DTO/属性类型与真实数据库和 API 契约语义对齐;优先用显式契约 record 和 DTO,而不是弱类型的匿名 payload 跨边界传递。可空性要诚实:null 应该精确表示"这个字段真的可以缺省",而不是用来掩盖"我没想清楚这里有没有值"。
反例
// 反例 1(前端):Hook 返回 any / 透传原始 DTO,消费方完全不知道形状
export function useWeeklyReport() {
return useQuery({
queryKey: ['weekly-report'],
queryFn: async (): Promise<any> => {
// ❌
const res = await fetch('/api/v1/weekly-reports')
return res.json() // ❌ 原始 DTO 直接抛出去
},
})
}
// 正确:类型化结果 + DTO 到模型映射
export function useWeeklyReport() {
return useQuery<WeeklyReport>({
queryKey: weeklyReportKeys.detail(id),
queryFn: async ({ signal }) => {
const dto = await api.getWeeklyReport(id, { signal }) // 返回 WeeklyReportDto
return mapWeeklyReportDtoToModel(dto) // ✅ UI 就绪模型
},
})
}
// 反例 2(后端):endpoint 直接返回 EF 实体
group.MapGet("/assets/{id}", async (Guid id, AssetsDbContext db) =>
Results.Ok(await db.Assets.FindAsync(id))); // ❌ 持久化实体泄漏到契约
// 正确:返回 DTO 契约
group.MapGet("/assets/{id}", async (Guid id, AssetsDbContext db) =>
{
var asset = await db.Assets.FindAsync(id);
return asset is null ? Results.NotFound() : Results.Ok(asset.ToDto()); // ✅ AssetDto
});
// 反例 3(后端):一个模块引用另一个模块的实现项目
// using Platform.Modules.Email; ❌ 应当 using ...Email.Contracts
其它要避免的:
- 应用代码里避免
any,除非真的没有现实可用的类型边界。 - 避免在能传播更好类型时,做不必要的类型断言。
- 避免在功能已拥有类型时,把类型散落到不相关的文件夹。
- 不要在
BuildingBlocks或 Contracts 项目里加基础设施依赖。 - 不要模糊掉那些已被架构测试强制的 API/Worker/模块边界。
仓库提醒:测试文件故意放宽了若干不安全类型规则。把它当成测试的例外,而不是生产代码的标准。
落地建议
- 类型从边界一路传到底:后端返回 DTO 契约,前端在 Hook 里把 DTO 映射成模型,组件只接触类型化模型。
- 跨模块只走 Contracts:需要别的模块的数据,引用它的
*.Contracts项目,绝不引用实现。 - 把契约改动当成契约改动:endpoint 形状变了,就要更新相关测试和生成的客户端;CI 的契约漂移检查会替你兜底。
- 可空性要诚实:API 真允许省略才设可空,别用
null掩盖语义。 any是信号不是工具:在应用代码里看到any,先问"是不是有一条类型边界被我跳过了"。
可迁移的那一层
抛开 TypeScript 和 C# 的具体语法,类型安全真正可迁移的认知是:类型的作用是把跨边界的承诺变成编译期可验证的事实,而每一处 any、每一个透传的原始实体、每一个实现引用,都是在某个边界上偷偷把这个承诺取消了。 strict 模式守住了链的内部,但链是在边界处断的。
设计任何跨层数据流时,与其只盯着每一层内部对不对,不如沿着数据走一遍,在每个交界处问:穿过这个边界时,类型跟着穿过去了吗,还是在这里被抹平成了 any? 让类型完整地走完从数据库到组件的全程,bug 就失去了在边界处冒头的缝隙。