返回博客

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 被任意闭包捕获后,TS 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,或在 usage site 显式标注类型

最低成本修法:在所有异步事件处理完成后,用 const snap = mutableVar 把值快照出来,后续代码操作 snap——TS 可以安全收窄 constpage.evaluate 返回值直接在目标变量声明时写明具体类型,不要依赖泛型推断。

修完后运行 npx tsc --noEmit 零输出即为通过。

排查速查

出现 Property 'X' does not exist on type 'never' 时,先确认三点:

  1. 报错变量是否 let 声明(而非 const)?
  2. 该变量是否在某个回调/闭包内被赋值?
  3. 是否从无 strict 的项目迁入 strict 项目?

三条都命中,基本就是这个坑。

一句话外卖

被闭包捕获的 let 变量,TypeScript strict 模式永远不会在闭包外收窄它——换成 const 快照,或在 usage site 显式标注类型。