ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TypeScript as const 完全指南:类型拓宽、字面量收窄与性能解读

TypeScript as const 完全指南:类型拓宽、字面量收窄与性能解读 1. 先弄明白as const 到底解决了什么问题我最早接触到as const的时候其实完全没把它当回事。印象里就是“把一个值变成只读”和普通const差不多嘛。真正让我觉得这东西“有点东西”是有一天在写一个配置表的时候发现 TypeScript 推导出来的类型和我想象中的完全不一样。你们看看这段代码const statusMap { pending: pending, processing: processing, done: done, } function handleStatus(status: string) { // 业务处理... }这个statusMap它的 key 是pending | processing | done这没问题。但它的 value 类型是什么是string。也就是说我在其他地方拿statusMap.pending去赋给一个类型为pending的变量时TypeScript 会毫不犹豫地报错。问题就出在**类型拓宽type widening**上。TypeScript 为了让你写着顺手会自动把pending这个字面量类型拓宽成string。拓宽本身是好事它符合绝大多数开发场景但当你确实需要“精确到字面量”的时候它就变成了绊脚石。后来我在很多项目代码里都看到过一种极其难受的写法为了不让类型拓宽有人会手动写一遍完整的联合类型然后再写一遍枚举对象两处代码一旦不统一运行期和编译期就会“各说各话”。as const就是来终结这个问题的。它把一个值断言成“最窄的类型”把你不想被拓宽的字符串、数字、布尔、对象、数组全部冻结在字面量层面。用过一次之后我的感受是这玩意儿不是锦上添花是刚需。顺便多说一句。很多从 C 转 TypeScript 的同学看到const就条件反射地想起const std::exception或者“const 函数”那套语义以为 TypeScript 里的as const也是干“运行期锁定”的。真不是。它和 C 的const指向完全不同的层次C 的const作用于编译期和运行期的变量可变性而 TypeScript 的as const仅仅作用在类型推导的层面对运行期代码零副作用。这个区别理解了后面所有用法都会顺理成章。2. 核心用法拆解三个最常用的 as const 姿势2.1 从“对象常量”开始把整个配置表冻结有相当长一段时间里我团队里写 TypeScript 的人分成两种一种人写配置表的时候反复用as把每个字段断言一遍另一种人干脆const config { ... } as const一步到位。后者写起来省事类型效果也更好。const videoCodecMap { h264: H.264, h265: H.265, av1: AV1, } as const这里的类型推导结果是什么是一个所有 key 和 value 都保持字面量的只读对象type VideoCodecMap { readonly h264: H.264; readonly h265: H.265; readonly av1: AV1; }请注意readonly在这里是附加产物。as const会把对象的每个属性标记为readonly这是它的设计使然目的是让你从类型层面就不该去改这个值——因为当你断言成字面量类型之后修改它往往意味着业务逻辑本身有问题。这种写法最常见的落地场景是状态机。比如一个订单的状态流转const orderStatus { created: created, paid: paid, shipped: shipped, completed: completed, cancelled: cancelled, } as const type OrderStatus typeof orderStatus[keyof typeof orderStatus]我见过很多团队用enum写状态机但enum在 TypeScript 里其实是个有争议的特性它既产生运行期对象又存在“数字枚举反向映射”这种容易踩坑的行为。用as const加keyof组合出来的“假枚举”在类型上并不输给enum而且它就是普通的 JavaScript 对象完全可控调试的时候看得见摸得着。2.2 数组的 as const只读元组的正确打开方式对象用as const已经很多人知道了数组用as const知道的人明显少一截。而且这里有个特别容易混淆的点普通数组用了as const之后会长得非常像元组。const modes [read, write, execute] as const这样推导出来的类型是type Modes readonly [read, write, execute]注意它不再是一个“数组”而是一个只读元组。这意味着你可以精准地写出modes[0]的类型是read、modes[1]的类型是write而不是贫血的string。这个特性在拆分变量时特别好用。比如你在处理一组固定顺序的参数const colorChannel [red, green, blue] as const function getColorValue(channel: typeof colorChannel[number]) { // channel 的类型是 red | green | blue }当然很多人会想如果我希望它保持数组类型每个元素又是联合类型那怎么办这种情况我建议用satisfies配合as const组合拳这一点后面讲实操的时候再展开。2.3 单独变量声明模块内常量的正确姿势还有一种场景可能只有一个单独的字符串常量不涉及对象也不涉及数组。有人可能会觉得这种地方as const没必要多此一举。但实际上它有没有意义取决于你希望这个变量的类型是字面量还是更宽的类型。const actionType submit as const推导出来的类型是submit而不是string。这种“窄类型”常量的价值在于当几个变量之间彼此有约束关系时类型系统能帮你检查出不一致。举个例子事件总线的场景const EVENT_SUBMIT event_submit as const const EVENT_CANCEL event_cancel as const type AppEvent typeof EVENT_SUBMIT | typeof EVENT_CANCEL function emit(event: AppEvent) { // ... } emit(EVENT_SUBMIT) // 正常 emit(event_cancel) // 也正常 emit(event_other) // 报错漂亮如果EVENT_SUBMIT只是普通的const声明它的类型是string那AppEvent就退化成了string上面第三行调用根本不会报错类型系统等于形同虚设。3. 性能完全解读别被“性能”两个字带进沟里3.1 运行时性能零开销真的零很多第一次看到“as const 性能”这个标题的人第一反应是查“运行速度提升百分之多少”。我先把丑话说在前面as const在运行时不产生任何代码它是一个纯编译期操作编译完之后你甚至找不到它的痕迹。我们来看一下编译产物。这一段代码const colorMap { red: #FF0000, green: #00FF00, blue: #0000FF, } as const编译成 JavaScript 之后就是const colorMap { red: #FF0000, green: #00FF00, blue: #0000FF, }你会发现as const在编译产物里连影子都没有。它不会把对象变成Object.freeze不会额外调用任何函数不会改变赋值行为。所以如果你正在优化某个性能瓶颈指望用as const提速那方向就错了——它优化的是你的编码效率和正确性不是程序运行时的 CPU 时间。3.2 真正的性能价值编译期推导更精准那么“性能”这个词在这里到底指什么我个人的理解是它提升的是类型推导效率和开发迭代性能。你可以这样对比。在大型项目里如果你大量使用宽泛的string类型编译器的工作模式是什么它要不停地做类型之间的兼容判断任何两个string之间都天然兼容所以编译器其实帮你“兜底”了很多错误。结果就是代码写起来很顺但错误发现得晚运行时才炸出来调试成本极高。等到 CI 类型检查过了你以为安全了实际上类型检查放过去很多隐患。用了as const之后类型收窄成为精确到字面量的联合类型。编译器要做的工作其实变少了因为不需要考虑“任意 string 和任意 string 是否兼容”这种大集合的匹配问题直接做字面量相等比较即可。从工程实践的角度看这种“编译期更严格、错误暴露更早”的模式省下的时间远超那一点类型推导的 CPU 开销——因为你不再需要花大量时间做运行时 debug 了。这里我补一句在实际项目里还有一个隐性收益编辑器提示的响应速度。宽泛类型的对象在 IDE 里做自动补全时候选集非常大而字面量联合类型的候选集是固定的几个所以 VSCode 的提示通常更干脆。写代码的人感受可能不明显但确实会流畅那么一点点。3.3 as const 不是 Object.freeze也不是深冻结这是我在代码审查里天天见到的误解。有人以为as const会让对象在运行时变成只读的所以尝试用as const来替代Object.freeze去保护一些全局配置数据。可以把话说得更直接一点。Object.freeze是运行时的浅冻结它阻止的是 JavaScript 引擎层面“给对象添加/修改属性”的行为而as const是编译期的类型承诺它阻止的是“你在 TypeScript 代码里写修改逻辑”的编译错误。const frozen Object.freeze({ count: 1 }) const asserted { count: 1 } as const // frozen.count 2 // 运行时报错严格模式下 // asserted.count 2 // 编译期报错有趣的是如果你对asserted做类型断言绕过类型检查比如(asserted as any).count 2运行期是能正常赋值的完全没有冻结效果。反过来frozen可以被赋给一个可变类型然后修改吗不行引擎层面就锁死了。所以我在项目里定过一个规矩**想冻结运行时对象用Object.freeze想让类型收窄到字面量用as const。两者各管各的谁也不替代谁。**如果我确实需要一个既在类型层面安全、又在运行时不可变的对象我会写成const config { retries: 3, timeout: 1000 } as const然后在初始化时用Object.freeze包一层。虽然多数场景下用不到这个组合但底层逻辑清楚之后组合起来就不慌。4. 高频场景实战这几个地方用 as const 会特别顺手4.1 配置表与“伪造的枚举”让拼写错误活不过编译期我在前面提过as const keyof typeof的组合可以用来替代enum。那这里我把完整套路写出来因为这个套路在很多中大型项目里是基本功。export const ProductType { subscription: subscription, oneTime: oneTime, bundle: bundle, } as const export type ProductType typeof ProductType[keyof typeof ProductType]之后你在业务代码里就可以引ProductType.subscription当值用引ProductType当类型用。最妙的是所有字面量检查都会生效const type: ProductType ProductType.oneTime // 通过 const type: ProductType oneTime // 通过 const type: ProductType one_time // 报错感叹号三连如果你团队里有人把oneTime写成了one-time如果没用这个模式这种错误往往要等到接口返回 400 或者数据匹配不上时才暴露。用上这个模式之后错误在写代码的当下就被系统提醒了。我自己的体会是这个套路最大的优点还不是类型安全而是可维护性。业务里所有状态相关的字符串都集中在同一个对象里改一处全部生效不会出现“类型定义改了但配置对象忘改了”的问题。相比enum它生成的 JavaScript 更可预测、更易 debug和 API 返回的数据结构天然一致。4.2 判别联合Discriminated Union与状态机as const 是地基看过很多 TypeScript 项目之后我越来越觉得判别联合是 TypeScript 最能打的功能之一。而as const是搭判别联合最好的材料之一。设想一个常见的请求状态场景type APIState | { status: idle } | { status: loading; startTime: number } | { status: success; data: unknown } | { status: error; message: string }这个联合类型用字符串字面量idle、loading等来作为判别字段。问题来了如果你在代码里维护这些状态字符串的来源时用的是宽泛的string类型那这个判别的可靠性就打了折扣。as const做的事情是让这些“判别字段”的来源变得可靠const State { idle: idle, loading: loading, success: success, error: error, } as const type APIState | { status: typeof State.idle } | { status: typeof State.loading; startTime: number } | { status: typeof State.success; data: unknown } | { status: typeof State.error; message: string }然后写一个 reducer 或者状态处理函数的时候TypeScript 能在每个分支里自动收窄类型。你在state.status State.success分支里访问dataTS 知道它存在在idle分支里访问dataTS 直接报错。长期维护下来状态流转的正确性能极大提升。这种“状态机友好”的特性在写playwright测试用例的时候深有体会。端到端测试里经常要判断页面处于什么状态比如登录态、加载态、空态、异常态。用as const把这些状态定义成字面量联合之后写断言的时候就不用靠记忆硬写字符串IDE 会直接给你候选值测试代码的稳定性也上来了。4.3 事件映射与回调注册让事件名和处理器强制配对前端项目里事件总线用得很频繁但事件名是字符串就意味着容易写歪。as const的类型收窄能力可以给事件总线做一层很轻量的约束。这个我帮同事改过一个真实案例。他原来的代码是这样type EventMap { user:login: (userId: string) void user:logout: () void cart:update: (items: number) void }事件名写在类型里看起来没问题。但实际写on(user:login, ...)的时候事件名这个参数还是普通stringTypeScript 并不会拦住你把user:login写成user:log in。这时候我们引入as const来定义事件名常量表const EventNames { userLogin: user:login, userLogout: user:logout, cartUpdate: cart:update, } as const function onK extends keyof typeof EventNames( eventName: (typeof EventNames)[K], handler: EventMap[(typeof EventNames)[K]], ) { // 具体实现... }嗯这样推导出来的类型已经能约束“事件名必须是 EventNames 里的一个值”并且“事件名和 handler 的类型必须配对”。我试过把(typeof EventNames)[K]这层泛型约束去掉事件名确实也能限制在联合类型里但 handler 的类型提示就退化了。用泛型约束之后神奇的事情发生了on(user:login, (userId) { /* userId 自动推导为 string */ }) on(cart:update, (count) { /* count 自动推导为 number */ }) on(user:logout, () {}) on(user:login , () {}) // 报错多了个空格都不行我把这种模式戏称为“靠类型把跑偏的手掰回来”。当然如果你的项目事件非常庞杂这种泛型映射可能有点重那至少可以保守地用keyof typeof EventNames限制事件名虽然牺牲了一点 handler 的自动推导但核心的事件名校验还是保住了。4.4 配合声明文件.d.ts 里的 as const 用法搜索热词里有“typescript 类型声明文件(.d.ts) 怎样编写”和“types types文件夹的声明文件 如何使用”我顺带讲一下as const在声明文件里怎么用因为这个知识点很多人问过。在.d.ts文件里你不能直接写as const因为声明文件里只有类型声明和描述。你真正要做的是用declare const来声明一个常量并用类型层面把它描述成字面量:// types/config.d.ts declare const DEFAULT_TIMEOUT: 1000 export { DEFAULT_TIMEOUT }但如果你要在.d.ts里描述一个“所有属性都是字面量、且全部只读”的配置对象你通常不能直接写as const而是手动写类型// types/app-config.d.ts export interface AppConfig { env: development | production | test logLevel: debug | info | warn | error timeout: 3000 }然后在使用端导入。如果你想在.ts文件里从一个 JSON 或普通模块里得到字面量类型再把这个类型导入到.d.ts里复用那是可行的。做法是在.ts里export const config { ... } as const然后export type Config typeof config之后声明文件里import(./config).Config这样引用。从实际经验来看.d.ts里直接塞as const是写不出来的新手容易在这卡住。记住一个原则.d.ts是类型声明as const是值断言两者不在一个频道。类型层面的字面量描述用类型语法自己写就行。5. 常见问题与排查思路实录5.1 as const 之后怎么取联合类型[number]和[keyof typeof]别搞混很多人在拿到一个as const对象或数组之后下一步的问题就是“我怎么把它转换成联合类型”。对于对象标准套路是const statusMap { a: A, b: B } as const type Status typeof statusMap[keyof typeof statusMap] // A | B对于数组标准套路是const roles [admin, editor, viewer] as const type Role typeof roles[number] // admin | editor | viewer我见过有同事对着数组用keyof typeof roles拿到的是一堆数组方法名push | pop | length | ...对着对象用[number]拿到的是undefined。这两个操作符不要混用。对象用keyof取键数组/元组用number索引取元素各自的适用对象完全不同。5.2 as const 不是万能的这几种场景它无能为力首先as const只能用在“值”上不能用在“类型”上。你写不了type T any as const // 这肯定是错的其次as const不能用在接口或者类型别名上。它不是类型操作符而是类型断言。这是 TypeScript 新手最容易撞的墙之一明明给对象加了as const之后很好用为什么给interface加就没有这个语法因为interface是纯粹的“类型”它没有“值”可以被断言。第三as const对于包含“动态值”的对象效果有限。比如const dynamicConfig { baseUrl: window.location.origin, retries: Math.random() 0.5 ? 3 : 5, } as constbaseUrl的类型会是string因为window.location.origin的运行期值推导为string是合理的retries的类型会是3 | 5因为三元表达式的字面量被收窄了。这种“部分字面量、部分宽类型”的结果很正常as const尽力而为但它不能探测运行期值的具体内容。5.3 不要为了用而用as const 的肩膀上还站着 satisfiesTypeScript 4.9 之后satisfies操作符和as const的组合才是真正优雅的完全体。satisfies用来“校验符合某个类型但保留推断的精确类型”as const用来“把推断收窄到最窄”。两者联合起来能解决一个常见的两难假设你要定义一个配置对象它必须符合某个接口但你又希望每个字段的字面量类型尽量窄。interface RuntimeConfig { mode: dev | prod level: debug | info | error }如果只写as const它当然窄但它不保证符合RuntimeConfig接口你得多写一遍接口校验如果只写satisfies RuntimeConfig它能校验但类型推导结果还是宽类型。组合一下const config { mode: dev, level: debug, } as const satisfies RuntimeConfig这样写config.mode的类型是dev并且它通过了RuntimeConfig的校验。要是在level里写一个接口以外的verbose编译期立刻报错。可以说这个组合是“字面量收窄 结构约束”双保险推荐改造存量代码时逐步引入。5.4 一个真实的项目重构案例最后分享一个实际重构案例也许能帮你直观感受as const在项目里的影响。去年接手的一个中后台项目里面有一坨“魔法字符串”泛滥的代码。比如到处直接写DRAFT、SUCCESS、FAILED而这些字符串确实在接口文档里有定义但是前后端之间没有任何类型层面的契约。久而久之代码里充满了if (status SUCCESS || status DRAFT)这种靠人肉记忆的判断。重构的时候我做了一次非常小的改动把所有的状态字符串集中到一个as const对象里然后导出对应的联合类型。改完之后仅仅一个 PR 的时间Pull Request 里的编译错误就抓出了 7 处拼写错误其中有 2 处是会导致运行期静默失败的低级问题——例如把DRAFT写成了DRAF接口返回后状态匹配不上页面卡在空白态不打开控制台根本发现不了。这之后我给团队立了一条不成文的规定**项目里的状态字符串、枚举值、事件名、配置键凡是会在多处使用的都必须用as const定义在一个文件里并导出对应类型。**代码风格一下子就整齐了。代码提示也变得非常友好写Status.SUCCESS时能直接看到候选状态根本不用去翻接口文档。如果你刚接触这个技巧我建议从最小的地方开始找一个你手头经常写错字符串的文件把里面的关键字符串改成as const定义。你会立刻感受到文字提示的变化。在一个不短的阶段里as const是我在 TypeScript 项目里最推荐的“低成本高收益”改动之一。它不改运行行为不引入额外依赖却在类型系统层面悄悄替你挡掉了不少低级错误。真正用顺手之后你反而会觉得原来写配置类和状态类代码居然可以这么省心。
返回列表