
类型安全 ≠ 数据安全:TypeScript 没承诺过的事
TypeScript 今年在 GitHub 上把 Python 和 JavaScript 都挤下去了,成了用的人最多的语言。我天天写它,编辑器一片绿,编译一次过。
然后呢?我照样把一个带 bug 的表单发上了线,三周后才被工单砸醒。
今天就聊聊这事:TypeScript 给你的那种"稳了"的感觉,到底能信几分。
先说说这个 bug 是怎么来的
场景很日常。我们做的是一个管理后台,里面有很多下拉框——商品产地、所属分类、审批状态,这种。
这些下拉框的选项不是写死在前端代码里的,而是运营同学在后台自己配的。运营在配置页里一行写一个选项,存到数据库里就是一个用换行符隔开的字符串,比如:
code小麦 水稻 玉米
前端拿到这个字符串,切一切,转成 Ant Design 下拉框要的格式。我写的转换函数长这样:
typescriptinterface DocFieldOption {
label: string; // 显示的文字
value: string; // 选中后的值
}
function mapOptions(optionsString: string): DocFieldOption[] {
return optionsString
.split('\n') // 按换行切开
.map(opt => ({ label: opt, value: opt })); // 转成下拉框要的格式
}类型写得清清楚楚,测了三个表单,全都好使。上线。
三周后来了张工单:某个下拉框里,多了一个空白的、点不动的选项。 就那么一条空行,杵在"水稻"和"玉米"中间,特别诡异。
凶手是一个回车
排查下来,原因简单到想笑:运营同学配选项的时候手抖多敲了个回车,存进数据库的字符串变成了:
code"小麦\n水稻\n\n玉米"
注意这里 ↑ 连着两个换行split('\n') 一切,中间就多出来一个空字符串 ''。我的代码毫无怨言地把它包装成了 { label: '', value: '' }——于是下拉框里就多了那行空白。
这时候你可能想问:TypeScript 呢?它不是号称类型安全吗?怎么一声不吭?
因为它没错。空字符串也是字符串,{ label: '', value: '' } 完全符合我定义的接口。TypeScript 检查的是"你的代码有没有按你说的形状来处理数据"——我的代码确实按说好的形状处理了,它当然放行。
问题是,数据本身不按说好的来,这事 TypeScript 根本看不见。
打个比方你就懂了
TypeScript 就像收货处贴在墙上的一张验收标准:"本仓库只收贴了面单的箱子,面单上必须写品名和数量。"
这张标准管得住你自己仓库里的流程。但供应商那头发什么货,它管不着——供应商压根没看过你这张标准。人家发来一个贴着面单、但里面是空的箱子,完全不违反"必须贴面单"这条规矩。
放到咱们的场景里:
- 你的 TypeScript 接口 = 贴在墙上的验收标准
- 后端返回的数据 = 供应商发来的货
- 运营在后台改配置 = 供应商换了包装方式,但没通知你
画成图就是这样,注意中间那条线:
code 后端这边(没有编译检查) │ 前端这边(有 TypeScript)
──────────────────────────────┼──────────────────────────────
│
运营在后台改下拉选项 │ interface 写得再好
↓ │ ↓
数据库里的配置变了 │ tsc 编译通过 ✅
↓ │ ↓
接口原样把数据吐给前端 ───────→│──→ 组件拿到就渲染
│
TypeScript 的地盘
只到这条线右边线的左边发生任何事——选项改了、必填变可选了、多了个空行——你的编译不会报错,CI 不会变红,一切静悄悄。等你知道的时候,一般已经是工单了。
修起来其实特别简单
回到那个函数,修复版就加了两行:
typescript// TypeScript 版
function mapOptions(optionsString: string): DocFieldOption[] {
return optionsString
.split('\n')
.map(opt => opt.trim()) // 把首尾空格顺手去掉
.filter(Boolean) // 空字符串直接扔掉,就这行救命
.map(opt => ({ label: opt, value: opt }));
}javascript// JavaScript 版,一模一样的逻辑
function mapOptions(optionsString) {
return optionsString
.split('\n')
.map(opt => opt.trim())
.filter(Boolean)
.map(opt => ({ label: opt, value: opt }));
}filter(Boolean) 的意思是:空字符串在 JS 里算"假值",直接过滤掉。相当于收货的时候顺手把空箱子扔了,别让它上货架。
一行代码的事。要是第一天就有它,这 bug 活不过五分钟。
顺便说一句,这篇文章里的代码我都实际跑过:用
tsc --strict编译零报错,拿"小麦\n水稻\n\n玉米\n \n高粱 "这种带空行带空格的脏数据实测,修复版输出干干净净的 4 个选项,原版确实会吐出空选项。放心抄。
更值钱的场景:开箱验货
下拉框多个空行还算小事,用户顶多觉得怪。但如果是订单金额、库存数量、审批状态这种直接影响业务的数据呢?后端哪天悄悄把数字改成字符串、把字段改成可能为 null,你的页面轻则显示 NaN,重则算错钱。
这种地方,光扔空箱子不够,得开箱验货——数据进来先检查一遍,形状不对当场报警,而不是带病渲染。最朴素的写法:
typescript// TypeScript 版:一个"验货员"函数
function isValidOptions(data: unknown): data is DocFieldOption[] {
return Array.isArray(data) &&
data.every(item =>
typeof item === 'object' && item !== null &&
typeof (item as any).label === 'string' &&
(item as any).label.length > 0 && // label 不许是空的
typeof (item as any).value === 'string'
);
}javascript// JavaScript 版
function isValidOptions(data) {
return Array.isArray(data) &&
data.every(item =>
typeof item === 'object' && item !== null &&
typeof item.label === 'string' &&
item.label.length > 0 &&
typeof item.value === 'string'
);
}我拿六种脏数据测过这个函数:空 label、缺字段、value 是数字、数组里混 null、整个不是数组……全部正确拦截,合法数据正常放行。
要是项目里这种边界很多,手写验货员会烦死,可以直接上 zod 这个库,写一份 schema,类型和校验都有了:
typescriptimport { z } from 'zod';
const OptionSchema = z.object({
label: z.string().min(1), // min(1):空字符串直接进不来
value: z.string(),
});
const OptionsSchema = z.array(OptionSchema);
// 类型直接从 schema 推出来,不用再手写 interface
type DocFieldOption = z.infer<typeof OptionSchema>;
// 接口数据进来先过这一道,不合法当场抛错
const options = OptionsSchema.parse(await fetchOptions());这些代码平时看着都像多余的,直到某天运营改了配置又没人告诉你——那一刻它就是"控制台一个明确的报错"和"生产环境一行诡异空白"的区别。
给你留个小作业
这周抽十分钟,在你自己的项目里全局搜一下 Promise<any>,尤其是封装请求的那几个文件。数数有多少个接口返回值,没做任何检查就直接进了页面渲染。
我出完这个 bug 之后搜过一次。那个数字让我沉默了一会儿。
最后总结成一句话,也是我这几年最大的心得:
TypeScript 保证的是"你按说好的形状写了代码",从来没保证过"世界会按说好的形状给你数据"。 前者归编译器管,后者归你自己管。尤其在运营、产品随手就能在后台改配置的系统里,这不是什么小概率事件——
它就是普普通通、随时会发生的星期二。
**评论区聊聊:**我这次是被一个空行坑了,但被"后端数据跟说好的不一样"坑过的,肯定不止我一个——字段突然变 null、数字变字符串、时间戳从秒变毫秒……你踩过最离谱的数据坑是什么?
如果这篇对你有帮助,欢迎关注公众号「前端达人」,每周更新实用前端干货。

