ES2026 与 TypeScript 进阶问题记录
记录语言内核层踩过的坑:ES 新特性边界、TS 类型体操的实际事故,每个问题都是实际遇到过并解决掉的。
问题一:手写 DeepReadonly 把函数撕成了对象
现象:内置 Readonly 只处理一层,自己写的递归版遇到函数类型字段直接烂掉——函数变成 { apply, call, bind... } 的对象类型。
原因:mapped type 对所有键展开,函数类型也被撕开递归。
解决:分支顺序是答案——先拦原始值和函数,再拦 Map/Set、数组,最后才走对象映射:
type DeepReadonly<T> =
T extends Primitive | Function ? T
: T extends Map<infer K, infer V> ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
: T extends Set<infer V> ? ReadonlySet<DeepReadonly<V>>
: T extends readonly (infer U)[] ? readonly DeepReadonly<U>[]
: { readonly [K in keyof T]: DeepReadonly<T[K]> };
UnionToIntersection 依赖分布式条件类型:(U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never——U 必须是裸类型参数才分发。
问题二:Promise.resolve(syncFn()) 的同步 throw 接不住
现象:.catch 链挂在 Promise 上,但 syncFn 的同步 throw 还是变成 uncaught error。
原因:Promise.resolve(syncFn()) 里 syncFn 在进入 Promise 世界之前就执行了,同步 throw 根本不走 reject 路径。
解决:用 ES2026 的 Promise.try,把同步 throw 和异步 reject 统一进 reject 路径:
const promiseTry = <T>(fn: () => T | PromiseLike<T>): Promise<T> =>
new Promise((resolve) => resolve(fn()));
// 封装"可能同步返回、可能异步拉接口"的工具函数
promiseTry(tool).then(trim).catch(handle) // 一条 catch 链搞定两种异常
问题三:TS 5 升级后装饰器全红
现象:关掉 experimentalDecorators 走标准版后,写了 5 年的方法装饰器签名报错。
原因:标准版方法装饰器签名从 (target, key, descriptor) 变成 (target, context)——没有 descriptor 了,直接改 prototype 的写法不再合法。
解决:通过返回替换函数拦截方法,元数据用 context.metadata:
function timed(target: (...args: any[]) => Promise<any>, context: ClassMethodDecoratorContext) {
return async function (this: any, ...args: any[]) {
const start = performance.now();
try { return await target.apply(this, args); }
finally { console.log(`${String(context.name)} ${(performance.now() - start).toFixed(1)}ms`); }
};
}
注意求值自上而下、调用自下而上;老项目迁移按装饰器种类逐个改,类装饰器最平缓。
问题四:泛型入参类型被拓宽成 string
现象:defineConfig({ mode: 'dev' }) 推出来 mode 是 string,每个调用点写 as const 又太脏。
原因:默认推断对对象字面量属性做拓宽。
解决:TS 5.0+ 的 const 类型参数,调用点零成本保留字面量:
function defineConfig<const T extends Record<string, unknown>>(config: T): T { return config; }
// 推出 { mode: 'dev' } 而不是 { mode: string }
// 进阶:模板字面量类型从路由字符串推导参数
type ExtractRouteParams<T extends string> =
T extends `${string}:${infer P}/${infer Rest}` ? { [K in P | keyof ExtractRouteParams<Rest>]: string }
: T extends `${string}:${infer P}` ? { [K in P]: string } : Record<string, never>;
// parseRoute('/user/:id/post/:postId') → { id: string; postId: string }
问题五:Records & Tuples 嵌套普通对象直接抛错
现象:想用 #{a: 1} === #{a: 1} 的结构相等做缓存 key,嵌套一层普通对象直接 TypeError。
原因:RT 只能递归包含原始值或其他 RT——值语义的前提是内容完全静态,函数/class 实例/DOM 引用都进不去。
解决:写 deepToRecord 逐层转换(Date/函数节点报错而不是静默丢);应用点收窄到状态快照比较、memo props 比较、Map 的对象 key。它不是"不可变 JS"的银弹。
