TypeScript strict 模式下 let + 闭包赋值导致类型收窄失效
把一个跑了好几个月的爬虫模块复制到新的桌面端 sidecar 包里,CI 立刻红了三行,报的全是 Property 'length' does not exist on type 'never'。代码没动一个字,原包 tsc 零错误,新包 strict: true 就挂——这是 TypeScript 一个反直觉但有意为之的设计。
现象
原工程(无 strict)一切正常。迁入开了 "strict": true 的包后,类似这样的代码:
let ssrHtml: string | null = null
page.on('response', async (res) => {
const body = await res.text()
if (!ssrHtml || body.length > ssrHtml.length) {
ssrHtml = body // 闭包内赋值
}
})
// ... 等待页面导航完成 ...
if (ssrHtml && ssrHtml.length > 50_000) {
// ^^^^^^^^^^^^^^^^
// error TS2339: Property 'length' does not exist on type 'never'
process(ssrHtml)
}三个 usage site,三行一模一样的报错,全在 ssrHtml.xxx 上。
根因
TypeScript 的流程分析(control flow narrowing)有一个关键假设:if 检查到变量使用之间,没有任何代码会修改这个变量。
对于 const,这个假设永远成立,TS 可以安全收窄。
对于 let + 被闭包捕获,TS 无法静态证明在 if (ssrHtml) 之后、ssrHtml.length 之前,那个注册好的 page.on 回调不会再次触发并把 ssrHtml 改回 null。于是它保守地拒绝收窄,在 strict 模式下将类型保持在联合类型 string | null,某些路径甚至被推断为 never。
原项目不报错,是因为没有开启 strict,放宽了对此类推断的要求——同一段代码其实一直带着这个隐患。
只要一个 let 变量出现在任何闭包体内(page.on / emitter.on / setInterval / Promise.race / for await / RxJS subscriber),TS 就会认为该闭包随时可能修改它——即使你知道时序上不会。if (variable) 之后紧跟 variable.prop 依然可能报 Object is possibly 'null' 或 Property 'X' does not exist on type 'never'。
这不是 Playwright 的问题,也不是爬虫特有的问题,任何"外层 let + 内层闭包写入"的组合都会触发。
另一个常见变体是 page.evaluate() 返回值类型坍塌:
const notes = await page.evaluate((max: number) => {
const out: any[] = []
// ...
return out
}, limit)
if (notes.length === 0) { // error: Property 'length' does not exist on type 'never'Playwright 的 evaluate<R, Arg> 在 strict + noImplicitAny 下有时会将 R 推断为 unknown 甚至 never,因为内部 any[] 与 DOM 类型系统的碰撞导致推断路径崩溃。
正解
方案 A:在 usage site 强转(改动最小)
if (ssrHtml && (ssrHtml as string).length > 50_000) {
process(ssrHtml as string)
}适合 1-3 个调用点、来不及重构的场景。缺点是每处都要写 as。
方案 B:快照到局部 const(推荐)
// 确认所有会修改 ssrHtml 的事件已经触发完毕后:
const ssr: string | null = ssrHtml
if (ssr && ssr.length > 50_000) {
process(ssr)
}const 不会被闭包重赋值,TS 可以自由收窄。多处使用时比方案 A 整洁得多。
方案 C:给 page.evaluate 结果加显式类型标注
const notes: Array<{ id: string; title: string }> = await page.evaluate(
(max: number) => {
const out: Array<{ id: string; title: string }> = []
// ...
return out
},
limit,
)不依赖 Playwright 的泛型推断,直接告诉 TS 结果是什么类型。跨版本更稳定。
方案 D:用对象包装可变状态(架构层)
const state = { ssrHtml: null as string | null }
page.on('response', async (res) => {
state.ssrHtml = await res.text()
})
if (state.ssrHtml && state.ssrHtml.length > 50_000) {
// ✅ TS 对 object property 的窄化更宽松
}TS 对对象属性的收窄处理比 let 变量要宽松(配合可辨别联合更好用)。适合重构窗口期。
最低成本修法:在所有异步事件处理完成后,用 const snap = mutableVar 把值快照出来,后续代码操作 snap——TS 可以安全收窄 const。page.evaluate 返回值直接在目标变量声明时写明具体类型,不要依赖泛型推断。
修完后运行 npx tsc --noEmit 零输出即为通过。
排查速查
出现 Property 'X' does not exist on type 'never' 时,先确认三点:
- 报错变量是否
let声明(而非const)? - 该变量是否在某个回调/闭包内被赋值?
- 是否从无 strict 的项目迁入 strict 项目?
三条都命中,基本就是这个坑。
一句话外卖
被闭包捕获的
let变量,TypeScript strict 模式永远不会在闭包外收窄它——换成const快照,或在 usage site 显式标注类型。