前段时间有个同事慌慌张张跑过来,说遇到一个特别诡异的 TypeScript 报错:never[] 赋值给 any[] 居然不行。他第一反应是加上 as any 蒙混过关,但被我拦住了。鼠标往报错行上一悬停,问题瞬间清楚了一半:那个类型根本不是裸的 never[],而是 readonly never[]。真正让 TypeScript 翻脸的,不是 never,是 readonly。
这种报错在项目里出现的频率比想象中高,尤其是刚把 strict 开起来的团队,或者 redux、zustand 这类喜欢用 Object.freeze 的场景。这篇文章我打算把这条报错从头到尾拆一遍:先复现现场,再解释 never[] 是怎么被推导出来的,然后讲讲 TypeScript 的类型兼容性规则为什么会让它报错,最后给几条可以直接抄的解决办法,以及项目里怎么从根上避免。
1. 先把报错精确复现出来:不是裸 never[],是 readonly never[]
1.1 一份最小可复现代码
很多人在搜索引擎里输入“TypeScript never[] 赋值 any[] 报错”,其实手里拿到的报错十有八九长这样:
ts复制const frozenEmpty = Object.freeze([]);
const target: any[] = frozenEmpty;
把这段代码放进 TypeScript 4.x/5.x 的严格模式里,报错信息大概是:
text复制Type 'readonly never[]' is not assignable to type 'any[]'.
The type 'readonly never[]' is 'readonly' and cannot be assigned to the mutable type 'any[]'.
请注意报错信息里的关键词:readonly never[]。如果把 readonly 去掉,变成这样一个赋值:
ts复制const source: never[] = [];
const target: any[] = source;
这行在绝大多数 TypeScript 版本里是不会报错的。never 是底部类型,never[] 在元素层面天然可以赋给 any[],甚至赋给 string[] 也没问题。真正阻断类型检查的,是外层那个“只读”的壳。
1.2 常见的三类触发方式
我在项目里见过的触发方式基本可以归成三类:
第一类,Object.freeze 包装空数组。上面那段代码就是典型,Object.freeze 的返回值被定义为 Readonly<T[]>,当 T 被推断成 never 时,整个类型就变成了 readonly never[]。
第二类,as const 断言。写了 [] as const 之后,类型会变成 readonly []。如果你把它再传给某个需要 any[] 的参数,同样会撞上“readonly 不能赋值给 mutable”的提示。
ts复制const empty = [] as const;
const target: any[] = empty; // 报错,readonly 数组不能赋给可变数组
第三类,泛型函数返回 ReadonlyArray<T>。比如封装了一个返回空数组的通用函数:
ts复制function getEmpty<T>(): ReadonlyArray<T> {
return [];
}
const list: any[] = getEmpty<never>();
// 报错:Type 'readonly never[]' is not assignable to type 'any[]'
这三类情况的共同点是:表面看都是“数组赋值数组”,但源类型和目标类型在“可变性”上有本质冲突。
1.3 为什么搜索关键词里只有 never[] 而没人提 readonly
这个现象很有意思。报错信息很短,readonly never[] 连在一起,大多数人的眼睛会第一时间抓到两个熟悉的词:never[] 和 any[]。于是搜索的时候自然就打出了“never[] 赋值 any[] 报错”,而那个 readonly 被下意识忽略了。
加上 readonly 和 never 在视觉上都比较长,报错面板里还往往有折行,很多人截图上第一行只看到 Type 'readonly never[]' is not assignable to type 'any[]',第二行的 “The type 'readonly never[]' is 'readonly'...” 又被折叠了。于是查了半天都以为是自己对 never 的理解有问题,其实是 readonly 在捣乱。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. never[] 是怎么被推导出来的:从空数组字面量到 readonly 包装
2.1 严格模式下空数组的类型漂移
要弄懂 readonly never[] 是从哪冒出来的,得先回到空数组字面量 [] 的类型推断。
在 TypeScript 严格模式下,如果你的代码是:
ts复制const empty = [];
把鼠标悬停上去,很多时候会看到 const empty: never[]。这个推导结果初看很反直觉:空数组为什么不是 any[],也不是 unknown[]?
因为 TypeScript 认为,这个数组里目前没有任何元素,而在严格模式下,它不愿意把这个空数组直接放宽成 any[]。于是它选择了最狭窄的类型 never[]。never[] 的意思是“这个数组里能放进去的元素,只可能是永远不可能存在的值”。由于任何值都不可能属于 never 类型,所以 never[] 实际上就是一个“不可能包含任何元素”的数组。
这也是为什么很多仓库里要求空数组必须显式标注类型,比如:
ts复制const users: User[] = [];
如果你不写,依赖它的上下文可能就会把它推断成 never[],然后在一个不相关的地方突然爆出类型错误。
2.2 Object.freeze 和 as const 如何把 never[] 变成 readonly never[]
空数组推出 never[] 只是第一步。Object.freeze 这个函数的类型签名大致是这样的:
ts复制freeze<T>(a: T[]): readonly T[];
也就是说,无论你传入什么数组,它返回的类型都会包一层 readonly。把 [] 传进去,T 被推断成 never,返回类型就是 readonly never[]。
as const 也一样。[] as const 在 TS 的类型世界里会被理解为“一个 readonly 的空元组”,通常显示为 readonly []。在老一些的 TypeScript 版本里,或者在某些工具类型的推导过程中,它也可能会以 readonly never[] 的形式出现。
所以真正让它变成 readonly 的,不是 never 本身,而是 Object.freeze、as const 或者泛型里用了 ReadonlyArray<T>。
2.3 泛型函数也会制造 readonly never[]
还有一种容易被忽视的来源:自己写的泛型函数签名里用了 ReadonlyArray。
比如下面这个函数:
ts复制function makeEmpty<T>(): ReadonlyArray<T> {
return [];
}
const result: any[] = makeEmpty<never>();
函数声明里明确写了返回 ReadonlyArray<T>,调用时把泛型参数传成 never,结果自然就是 readonly never[]。赋值给 any[] 时,TS 不会因为元素是 never 就放过 readonly 的检查。
我在代码评审里见过好几次这种问题,都是因为某个基础库的返回类型定义成了 ReadonlyArray,调用方又想把它塞进一个 any[] 变量。这时候改调用方不如改库函数签名,把返回类型改成 T[]。
3. 类型兼容性规则:为什么 never 能赋给 any,数组却被拒绝
3.1 先看一个结论:裸 never[] 本来是可以赋给 any[] 的
TypeScript 的类型系统里,never 是所有类型的子类型。这意味着任何需要 any、string、number 的地方,都可以接受一个 never 类型的值。用条件类型验证一下:
ts复制type IsNeverAssignableToAny = never extends any ? true : false;
// 结果是 true
数组类型在这个层面也是协变的:
ts复制type IsNeverArrayAssignableToAnyArray = never[] extends any[] ? true : false;
// 结果是 true
这个结果说明,如果纯粹是“元素类型从 never 变成 any”的赋值,TypeScript 没有理由拒绝。很多人在这一步就卡住了:明明条件类型都返回 true 了,为什么实际代码还报错?
因为实际代码里的源类型不是 never[],而是 readonly never[]。
3.2 真正的拦截点:数组的可变性检查
readonly never[] 和 never[] 在结构上是不同的。ReadonlyArray<T> 这个类型里没有 push、pop、splice 这类会修改原数组的方法,而 any[] 是完整的可变数组,拥有全部修改方法。
当 TypeScript 检查 readonly never[] 是否能赋给 any[] 时,它要确认目标数组上的那些修改方法在源类型上存在。源类型是一个只读数组,根本没有 push 和 pop,于是赋值不成立。
你可以这样理解:readonly never[] 是一把锁死的空保险箱,any[] 是一个可以随便往里塞东西的普通箱子。把保险箱当成普通箱子用,等于允许别人破坏“只读”这个承诺,TypeScript 绝不会同意。
3.3 数组协变带来的先入为主
很多人对数组协变很熟,知道 Dog[] 可以赋给 Animal[],所以天然会认为“只要元素类型兼容,数组之间就可以赋值”。这个直觉在普通数组之间基本正确,但一旦碰到 readonly 和 mutable 的转换,就要额外多走一步。
readonly 修饰的不是元素类型,而是整个容器的操作权限。它和元素类型是两套独立的检查维度:
text复制元素类型检查:never → any ✓
容器可变性检查:readonly → mutable ✗
两条检查全部通过,赋值才成立。现在第二条挂了,所以整体报错。这也是为什么修复的方向不是去处理 never,而是去处理 readonly。
4. 快速解决办法:五条可以抄作业的修复路径
4.1 最快:展开运算符转成新数组
遇到这种情况,最简单直接的办法是用展开运算符拷贝出一个新的可变数组:
ts复制const frozenEmpty = Object.freeze([]);
const target: any[] = [...frozenEmpty];
展开运算符会创建一个全新的数组,新数组天然是 mutable 的。它的元素类型会保留原来的 never,但 never[] 赋给 any[] 没问题。这样既保留了原数组的只读性,又让目标变量拿到了一个可以修改的副本。
这个方案我几乎每天都用,特别是在把 redux 里的只读状态传给某个老 API 的时候。
4.2 语义最清晰:Array.from
如果觉得展开运算符在代码里不够显眼,可以换成 Array.from:
ts复制const frozenEmpty = Object.freeze([]);
const target: any[] = Array.from(frozenEmpty);
Array.from 明确表达“从一个数组类对象创建一个新数组”,语义比 [...arr] 更直白一点。而且它天然返回一个 mutable 数组,不用担心 readonly 的问题。
如果源数组的元素类型是 never,你甚至不用写泛型参数,直接调用就能赋给 any[]。如果要赋给别的具体类型数组,再显式加泛型就行:
ts复制const target: string[] = Array.from(frozenEmpty);
这里 never 可以赋给 string,同样不会报错。
4.3 下策:类型断言,只建议在遗留代码里用
如果你是接手一个老项目,暂时不想动源头,可以用类型断言:
ts复制const frozenEmpty = Object.freeze([]);
const target = frozenEmpty as any[];
有些 TypeScript 版本会提示这个断言“可能是个错误”,这时可以用 unknown 做一次桥接:
ts复制const target = frozenEmpty as unknown as any[];
我不建议把这种写法作为首选。as any 等于告诉 TypeScript:“这里你不用检查了,我说了算。”一旦以后 frozenEmpty 真的被塞了某些东西,或者调用方对这个数组做了不安全的修改,错误就会被推迟到运行时。
但不可否认,它确实能解决眼前的编译问题。如果只是临时联调,或者是从某个不受控的第三方库拿到的类型,用一下问题也不大,前提是代码评审时要让大家知道这里为什么要绕。
4.4 治本:调整源头类型,让目标变成 readonly 或者源变成 mutable
如果这个代码是你自己写的,最好从源头解决。
方向一:把目标类型声明成 readonly。
如果你后续只是读取这个数组,不需要修改它,那么把 any[] 改成 readonly any[] 是最优雅的方案:
ts复制const frozenEmpty = Object.freeze([]);
const target: readonly any[] = frozenEmpty;
这样源和目标在可变性上就对齐了,完全不报错。
方向二:让源类型变成 mutable。
如果源头是一个空数组字面量,可以直接标注:
ts复制const emptySource: never[] = [];
const target: any[] = emptySource;
但如果源头是从 Object.freeze 来的,你没办法在不动数据的情况下把它变成 mutable,所以这个方向只适用于你可以修改初始声明的情况。
方向三:把泛型函数的返回类型从 ReadonlyArray<T> 改成 T[]。
ts复制function getEmpty<T>(): T[] {
return [];
}
const list: any[] = getEmpty<never>();
这样函数调用方拿到的就是普通数组,问题自然消失。
4.5 通用工具:写一个 toMutable 帮助函数
如果你的项目里经常遇到 readonly 数组要转 mutable 的场景,我建议抽一个通用函数出来:
ts复制type Mutable<T> = { -readonly [K in keyof T]: T[K] };
function toMutable<T extends readonly unknown[]>(arr: T): Mutable<T> {
return Array.from(arr) as Mutable<T>;
}
用的时候就很简洁了:
ts复制const frozenEmpty = Object.freeze([]);
const target: any[] = toMutable(frozenEmpty);
这个工具函数比到处写 as unknown as any[] 干净得多。它不仅保留了原始数组的元素类型结构,还在编译期明确做了“只读转可变”的转换。以后如果源类型从 never[] 变成 string[],这里依然能正常工作。
5. 工程上怎么避免下次再踩:从代码规范到排查路径
5.1 用 ESLint 规则拦住无谓的 as any
很多项目里 as any 之所以泛滥,是因为没有规则约束。我建议在 ESLint 配置里打开 @typescript-eslint/no-unnecessary-type-assertion,这个规则可以识别出“没有实际必要”的类型断言,减少无意义的 as any。
同时可以打开 @typescript-eslint/consistent-type-assertions,把 as any 的写法限制住,比如要求必须写明理由,或者不允许直接使用 as any,只能使用 as unknown as 某个具体类型。这样至少能让代码评审的人注意到“这里有一个类型逃逸口”。
5.2 在项目基础设施里放好 Mutable 工具类型
与其每次遇到 readonly never[] 再临时处理,不如在项目公共类型文件里提前准备几个工具类型。
我通常会在 src/types/array.ts 里放:
ts复制export type Mutable<T> = { -readonly [K in keyof T]: T[K] };
export function toMutable<T extends readonly unknown[]>(arr: T): Mutable<T> {
return Array.from(arr) as Mutable<T>;
}
export function toReadonly<T extends unknown[]>(arr: T): readonly T[] {
return Object.freeze([...arr]);
}
团队里其他人遇到同类问题,直接 import 这个工具函数就行,不需要每个人重新研究一遍类型兼容性。
5.3 遇到 never[] 类报错的三步排查路径
以后再看到类似报错,我建议按这个顺序排查:
第一步,鼠标悬停到源变量上,看完整类型。如果类型里带 readonly,那就不用怀疑 never 的赋值能力了,直接处理 readonly 问题。
第二步,写一个临时条件类型验证基础兼容性:
ts复制type Check = never[] extends any[] ? true : false;
如果这个结果是 true,说明裸类型本身没问题,报错一定出自其他包装类型。
第三步,沿着 never[] 的推导链路往回看。它来自空数组字面量、Object.freeze、as const,还是某个泛型函数的 ReadonlyArray 返回类型?找到源头之后,再决定是改目标类型、改源声明,还是用工具函数转换。
这套排查路径我在团队里推了大半年,基本能覆盖 90% 的数组类型误报。
5.4 一句话口诀
“先看 readonly,再看泛型,最后才轮到 as any。”这句话我每次给别人讲这个报错都会重复一遍。看着像是在讲 TypeScript 类型系统,其实更多是排查顺序的问题。很多人直接跳到 as any,是因为没看清报错信息里的第二行。只要先花十秒钟把完整报错读完,这个问题根本算不上什么难题。
我在实际项目里的体会是,这类报错最怕的不是复杂,而是“看似熟悉”。never 本身就够抽象了,再叠一个 readonly,新手很容易被带偏。但只要拆开看,底层逻辑非常简单:元素类型检查过了,容器可变性检查没过。补上一个新数组或者对齐 readonly 修饰,问题就结束了。
最后再分享一个小技巧。如果你手头有一个经常被 Object.freeze 处理的空数组,与其每次在调用处写 [...frozenEmpty],不如直接把初始声明改成显式的可变数组:
ts复制const emptyList: any[] = [];
这样源头就是 mutable 的,后面所有赋值都不会因为 readonly 报错。这算是我踩过几次坑之后总结出来的习惯:空数组这种“看似无害”的声明,反而是类型推断里最容易埋雷的地方。
