我的 TypeScript 代码里一个声明为 Dog 数组的变量,运行时遍历它时竟拿到了一个根本没有 bark 方法的对象、直接报错,排查半天发现是我把它当 Animal 数组传给了一个函数、那函数往里塞了别的动物的深度复盘

我有段处理动物的 TypeScript 代码,基类 Animal、子类 Dog(有 bark)和 Cat(有 meow);我有个 Dog 数组,确信全是狗,遍历时放心调 dog.bark(),写了很久都好好的。可有天线上报错 TypeError: dog.bark is not a function,我懵了——这明明是 Dog 数组、编译器全程绿灯,怎么会遍历出一个没有 bark 的东西?打出数组一看倒吸凉气:本应全是狗的数组里赫然混进了一只 Cat。顺代码回扒真凶现身:我曾把这个 Dog 数组传给一个签名为 (animals: Animal

我的 TypeScript 代码里一个声明为 Dog 数组的变量,运行时遍历它时竟拿到了一个根本没有 bark 方法的对象、直接报错,排查半天发现是我把它当 Animal 数组传给了一个函数、那函数往里塞了别的动物的深度复盘

这是一次让我对"类型系统的'允许',不等于'安全'"有了刻骨认知的事故。我有一段处理动物的 TypeScript 代码,定义了基类 Animal 和子类 Dog(多了个 bark())、Cat(多了个 meow())。我有一个 Dog[] 数组,确信里面全是狗,遍历时放心地调 dog.bark()。这段逻辑写了很久,一直好好的。

可有一天,线上报错了:TypeError: dog.bark is not a function。我盯着报错懵了——这明明是个 Dog[],TypeScript 编译器全程绿灯、一个警告都没有,怎么会遍历出一个没有 bark 方法的东西?我把数组内容打出来一看,倒吸一口凉气:我那个本应"全是狗"的数组里,赫然混进了一只 Cat!顺着代码往回扒,真凶找到了:我曾把这个 Dog[] 传给了一个签名为 (animals: Animal[]) => void 的函数;在那个函数里,它觉得"这是个 Animal 数组嘛,塞只 Cat 进去天经地义",于是 animals.push(new Cat());而这只 Cat,就这样顺着同一个数组引用,溜回了我那个声明为 Dog[] 的变量里——TypeScript 全程没有报错。

故障现场:Dog[] 里凭空多了一只 Cat

我把出事的代码精简还原出来,问题一目了然:

class Animal { name = ""; }
class Dog extends Animal { bark() { return "汪"; } }
class Cat extends Animal { meow() { return "喵"; } }

// 一个"往 Animal 数组里加一只猫"的工具函数, 看起来完全合理
function addACat(animals: Animal[]) {
  animals.push(new Cat());   // Cat 是 Animal, push 进 Animal[] 合情合理
}

const dogs: Dog[] = [new Dog(), new Dog()];

// 关键: 把 Dog[] 传给期望 Animal[] 的参数 —— TS 【允许】! (数组协变)
addACat(dogs);   // ✓ 编译通过, 无任何警告
//      ^^^^ TS 认为 Dog[] 是 Animal[] 的子类型, 可以传

// 现在 dogs 里其实是 [Dog, Dog, Cat], 但它的静态类型还是 Dog[]
for (const dog of dogs) {
  dog.bark();    // 运行到那只 Cat 时: dog.bark is not a function ✗
}

看着这段代码我半天没回过神:每一行单独看都"合理"——DogAnimal,所以 Dog[] "当然"能当 Animal[] 用;CatAnimal,所以往 Animal[] 里 push 一只 Cat "当然"合法。可这两个"当然"合到一起,就捅出了大篓子:一只 Cat 被合法地塞进了一个实际上是 Dog[] 的数组。TypeScript 之所以放行第一步(把 Dog[] 传给 Animal[]),是因为它对数组采取了协变(covariance)——把"子类型的数组"看作"父类型的数组"的子类型。这在"只读"时很直观、很方便,可一旦那个函数往数组里写,协变就变得不安全了。TS 为了实用性,默认接受了数组协变这个'不健全(unsound)'的便利,而我恰好踩在了它放行、却并不安全的那条缝上。

第一件事:搞懂协变与逆变——子类型的数组,为什么不能随便当父类型的数组写

冷静下来,我去补了"型变(variance)"这一课,才明白这个坑的根源:

【协变、逆变,与"数组协变为何不安全"】

前提: Dog 是 Animal 的子类型(Dog <: Animal), Dog 能用在任何要 Animal 的地方

协变(covariance): 如果 Dog <: Animal, 那么 Dog[] <: Animal[]
  - 直觉: "一筐狗" 也是 "一筐动物", 看起来很合理
  - 【只读时安全】: 从 Animal[] 里读出来的, 当 Animal 用, 没问题
  - 【写入时不安全】: 把它当 Animal[] 来写, 就能往里塞任何 Animal(比如 Cat)
    → 而它底层其实是 Dog[], 被塞进 Cat 就破坏了"全是 Dog"的假设

为什么"读安全、写不安全":
  - 读: 要的是"至少是个 Animal", Dog 满足, 安全(协变方向)
  - 写: 要的是"能接受任何 Animal", 但 Dog[] 只能接受 Dog, 不能接受 Cat
    → 写入的安全方向是【逆变(contravariance)】, 和协变【相反】
  → 一个既能读又能写的可变数组, 同时要求协变(读)和逆变(写),
    二者冲突 → 可变数组的协变本质上就是【不健全】的

TypeScript 的取舍:
  - TS 为了实用/方便, 【故意】让数组(及方法参数)协变, 接受了这点不健全
  - 它赌"你不会真的往一个传进去的数组里乱写", 大多数时候没事
  - 但一旦有人真的写了(本次的 push), 类型安全的保证就破了, 编译期却不报

【真正健全的做法】: 区分"只读"和"可变"
  - 只需读 → 用 ReadonlyArray / readonly Animal[], 它【没有 push】,
    协变就完全安全了
  - 需要写 → 别接受比实际类型更宽的数组

这一下点醒了我:我以为"Dog[] 是 Animal[]"是一条无条件成立的真理,可它只在"只读"的语境下成立;一旦涉及"写入",这个看似天经地义的子类型关系,就不再安全了。而 TypeScript 出于实用主义,默认放行了这种不安全的协变——它没有强迫我区分"我把这个数组传出去,是只让你读、还是允许你写"。我把一个我珍视其"全是狗"这一不变量的数组,交给了一个有权往里写的函数,等于亲手把"保持全是狗"的控制权拱手让人,而类型系统因为它选择的那点不健全,没能拦住我。编译器的'允许',从来不等于'这么做一定安全'。

第二件事:正解——用 readonly 表达"只读意图",别把可写的宽数组交出去

找到根因,正解就清晰了:当一个函数只需要读一个数组、不该往里写时,把参数声明为 readonly(ReadonlyArray)——它没有 push 等写方法,协变就完全安全;而当你珍视一个数组的某个不变量(比如"全是 Dog")时,绝不要把它当成更宽的可写类型(Animal[])交给别人。用类型如实表达""还是""的意图。

// 错误: 参数是可写的 Animal[], 调用方传 Dog[] 时, 函数能往里塞 Cat
function addACat(animals: Animal[]) {
  animals.push(new Cat());   // 破坏了调用方 Dog[] 的不变量
}

// 正解1: 只读的函数, 声明 readonly —— 它根本没有 push, 写不了, 协变安全
function describe(animals: readonly Animal[]) {
  for (const a of animals) console.log(a.name);
  // animals.push(...)   // ✗ 编译错误: readonly 数组没有 push, 想乱写都不行
}
const dogs: Dog[] = [new Dog()];
describe(dogs);   // ✓ 安全: describe 只能读, 塞不进 Cat

// 正解2: 确实要"加一只动物", 别改调用方的数组, 返回新数组(不可变更新)
function withACat(animals: readonly Animal[]): Animal[] {
  return [...animals, new Cat()];   // 产出新数组, 不动原数组
}

// 正解3: 若函数本就该接受任意 Animal 并写入, 那调用方就【不该】传 Dog[]
//        ——一开始就别声明成 Dog[], 或别把它交给会写的函数

这套做法的精髓,是让类型承载"意图":readonly Animal[] 明确地说"我只读、不会动你的数组",于是调用方传任何子类型数组进来都绝对安全;而可写的 Animal[] 则意味着"我可能往里塞任何 Animal",这种权力就不该轻易交给一个珍视具体类型不变量的数组。把"只读"和"可变"用类型区分开,协变那点不健全就再也伤不到你——因为不安全的""被 readonly 从源头堵死了。

【和型变和平共处的几条实践】

1. 函数只读参数 → 一律 readonly T[](ReadonlyArray)
   既表达意图, 又让"协变 + 写入"的不安全组合编译期就报错

2. 不可变更新 → 别原地改传进来的数组, 返回新数组([...old, x])
   避免任何"通过共享引用偷偷改坏别人数据"的可能

3. 珍视的不变量(全是Dog) → 别让它以更宽的可写类型(Animal[])离开你的控制

4. 知道 TS 哪里"不健全": 数组协变、方法参数双变(bivariance)、any、
   类型断言 as —— 这些是 TS 为实用主义放弃的健全性, 用到时多留个心眼

5. 开 strictFunctionTypes: 让【函数类型】的参数检查更严(逆变),
   但注意它【不管】方法(method)参数, 方法参数仍是双变的

第三件事:其他"TS 放行了、却并不安全"的坑

顺着"编译器允许 ≠ 运行时安全"这条线,我把 TS 里同类的"不健全"角落都梳理了一遍,它们都在我"以为类型检查保护着我"的地方埋着:

第一个,方法参数双变(bivariance)。和数组协变同源——一个声明为 (e: Event) => void 的回调位置,TS 允许你传 (e: MouseEvent) => void,可运行时真传进来的可能是别的 Event,回调访问 MouseEvent 独有属性就炸。

第二个,类型断言 as 强行指鹿为马const x = data as Dog 只是告诉编译器"当它是 Dog",不做任何运行时校验,data 实际是 Cat 也照样通过,用到 bark 才炸。as 是关掉检查、自担风险。

第三个,any 像瘟疫一样扩散。一个 any 进来,它流经的地方类型检查全失效,错误的类型一路畅通无阻地传播,直到运行时某处崩掉。外部数据该用 unknown 强制收窄,而非 any。

第四个,索引访问默认不检查越界/缺失arr[100]obj[key] 的类型是 T 而非 T | undefined(除非开 noUncheckedIndexedAccess),实际取到 undefined 再 .foo 就炸。

第四件事:协变、逆变、双变,读写场景下谁安全

我把型变在""和""场景下的安全性整理成一张表,这是我现在想清楚"能不能把子类型数组当父类型用"的依据:

型变 含义 读场景 写场景 TS 默认
协变 Covariant Dog[] 可当 Animal[] ✓ 安全 ✗ 不安全(能塞 Cat) 数组、方法返回值
逆变 Contravariant Animal 处理器可当 Dog 处理器 ✓ 安全(写入方向) strict 下的函数参数
双变 Bivariant 协变逆变都允许 ✗ 可能不安全 方法参数(method)
不变 Invariant 类型必须完全一致 最安全但最死板

这张表让我看清:"子类型的数组能不能当父类型用"没有统一答案,取决于你拿它来读还是写——只读时协变安全,要写时协变就危险。TS 为了好用默认让数组协变、方法参数双变,把安全的判断责任部分交给了我;readonly 则是我把"只读"这个让协变重新安全的前提,显式地告诉编译器。

第五件事:我对"类型检查通过"的几个想当然

这次事故,本质是我把"TypeScript 编译通过"无条件地等同于了"类型绝对安全"。把这些想当然列出来,每一条都值得警惕:

我曾经的想当然 事故教我的真相
"编译器允许,就说明这么做是安全的" TS 有意放弃了部分健全性换实用,允许 ≠ 安全
"Dog[] 是 Animal[],随便互换没问题" 只读时成立;涉及写入,协变就不安全了
"声明成 Dog[],运行时它就全是 Dog" 协变让别人能往里塞 Animal,不变量会被悄悄破坏
"传数组进函数,函数动不了我的数组" 传的是同一引用,函数 push 会改到你的原数组
"类型系统是健全的,能挡住所有类型错误" TS 在数组协变/方法双变/any/as 等处不健全
"参数加不加 readonly 无所谓,反正我没改" readonly 表达只读意图、堵死不安全的写,差别巨大

第六件事:传递集合、依赖类型检查时,我现在的自检习惯

现在每当我把一个集合交给别的函数、或依赖"编译过了就安全"时,我都会先按这张图问自己:

这张图的精髓,是"分清交出去是给人读还是给人写、只读用 readonly、写入别交出更宽的类型、并清楚 TS 哪里不健全别盲信编译通过"设计就用 readonly/不可变更新把只读与可变分清、不轻易交出珍视不变量的可写集合、排查就顺着这个意外的类型往回找它从哪个协变/双变/as 的缝里溜进来的这套习惯,让我从"编译过了就万事大吉"变成了"知道 TS 的允许有边界、在不健全处自己设防"——核心始终是:TypeScript 为实用主义有意放弃了部分健全性(数组协变、方法参数双变、any、类型断言 as);Dog[] 当 Animal[] 只读安全、写入则别人能 push 进 Cat 破坏全是 Dog 的不变量、编译期却不报;正解是只读参数用 readonly(没有 push、协变安全)、不原地改而返回新集合、不把珍视不变量的可写集合交出更宽的类型。

我立下的几条规矩

这场"Dog[] 里混进 Cat"的事故,换来了我用 TypeScript(及一切类型系统)时,刻进骨子里的几条铁律:

  1. "编译器允许"不等于"运行时安全";TS 为实用有意放弃了部分健全性,允许之处未必都安全。
  2. "子类型的数组是父类型的数组"只在只读时成立;一旦涉及写入,这个协变就不安全了。
  3. 函数只需读集合,就把参数声明为 readonly——它没有 push,既表达意图,又让不安全的写编译期报错。
  4. 别原地修改传进来的集合;要"增删改"就返回新集合,杜绝通过共享引用偷改别人数据。
  5. 珍视某个不变量(全是 Dog)的集合,绝不要以更宽的可写类型(Animal[])交出自己的控制权。
  6. 清楚 TS 的不健全处:数组协变、方法参数双变、any、类型断言 as——用到时多留个心眼、自己设防。
  7. 把"编译通过"理解为"在 TS 健全的范围内没问题",而非"运行时绝对类型安全"。

附:我现在区分"只读传入"和"可变拥有"的写法

这是我现在写任何接收集合的函数时,固定遵循的一套写法——用类型显式区分"我只是借来读读"和"这集合归我管、我要改",让那只 Cat 再也没机会溜进别人的 Dog 数组:

// 1) 只读借入: 一律 readonly, 编译器替你堵死一切写操作
function totalAge(animals: readonly Animal[]): number {
  // animals.push(...)     // ✗ 编译错误, readonly 没有 push
  // animals[0] = new Cat()// ✗ 编译错误, readonly 不能下标赋值
  return animals.reduce((s, a) => s + (a as any).age ?? 0, 0);
}

// 2) 要"变更" → 不可变更新, 返回新集合, 绝不动入参
function addAnimal(animals: readonly Animal[], one: Animal): Animal[] {
  return [...animals, one];   // 新数组, 原数组纹丝不动
}

// 3) 真要"拥有并维护"一个可变集合 → 自己持有, 不向外暴露可写引用
class Kennel {
  #dogs: Dog[] = [];                       // 私有, 外界碰不到
  add(d: Dog) { this.#dogs.push(d); }      // 只通过受控方法写, 类型卡死是 Dog
  list(): readonly Dog[] { return this.#dogs; }  // 对外只给只读视图
}

const kennel = new Kennel();
kennel.add(new Dog());        // ✓ 只能加 Dog
// kennel.list().push(new Cat())   // ✗ 只读视图, 塞不进 Cat
for (const d of kennel.list()) d.bark();   // ✓ 永远全是 Dog, 安全

这套写法把我这次的教训钉死在了类型里:借出去读的,用 readonly 卡死写;要变更的,返回新集合而非原地改;要长期维护一个有不变量的集合,就自己私有持有、只通过受控方法写、对外只暴露只读视图。核心就一句话——"能往里写"是一种权力,这种权力绝不能随随便便、跟着一个被放宽的类型,流到我控制不了的地方去。把写的权力收紧,那只凭空出现的 Cat,自然就无处遁形了。

这件事过后,我把项目里所有接收数组、却没标 readonly 的函数都过了一遍,统计下来竟有上百个。其中绝大多数其实只读不写,只是当初谁也没想过加 readonly——它们就像一扇扇没上锁的门,平时没人闯进去乱写,可只要有一个人哪天顺手 push 一下,就可能重演这场 Dog 数组里冒出 Cat 的闹剧。我花了两天把只读的全标上 readonly,改完那一刻心里踏实了不少:不是因为我相信同事不会乱写,而是因为现在编译器替我把那些门都锁上了,谁想乱写都过不了编译。这种把安全建立在类型约束、而非建立在自觉之上的踏实,是这次事故给我最实在的回报。

更深一层的收获,是我从此对所有让我觉得顺手的语言特性,都多了一份探究其代价的习惯:它为什么这么设计?为了换取什么方便,牺牲了什么严格?在哪些边角它会失效?这种追问没让我变得畏手畏脚,反而让我用得更踏实——因为我终于不再把工具当成不会出错的魔法,而是当成一个有脾气、有边界、值得我去摸清的伙伴。

写在最后

回头看,这场由"数组协变 + 写入"引发的"Dog 数组里混进 Cat"事故,真正教给我的,远不止"用 readonly、返回新数组"这一个技巧。它让我对"一个为了'好用'而做出妥协的规则, 会在'大多数情况下'都对你很友好、很顺手; 可正是这份友好, 会让你忘记它当初是用'一点点正确性/安全性'换来的——而你迟早会踩在那'一点点'被牺牲掉的地方, 摔得猝不及防",有了一次刻骨的体会。我栽跟头,是因为我把一个'为方便而刻意不那么严格的规则', 当成了'严格、无条件正确的规则'来信赖——TS 让 Dog[] 能当 Animal[] 用, 这在我'读'它的无数次里都既方便又正确, 于是我彻底放下了戒心, 默认'这俩就是能随便互换';我没意识到, 这条便利的规则从被设计出来那天起, 就在'写入'这个场景下是不成立的——它是 TS 用'写入时的安全'换来的'读取时的方便';我享受了无数次方便, 却在某次'写入'时, 一头撞上了那个被牺牲掉的安全这让我领悟到一个关于"规则、妥协与其代价"的深刻认知:许多让我们觉得'顺手、好用'的规则与默认行为, 背后都是一次'用某种严格性/正确性, 换取便利性'的妥协; 这种妥协在绝大多数日常场景下不会暴露, 因此我们会渐渐忘记它是有代价的、把'方便'误当成了'无条件的正确';而风险, 恰恰潜伏在那个'当初为了方便而被牺牲掉的少数场景'里——它平时不出现, 一出现就是你最没设防的时候;所以真正的成熟, 是既享受规则带来的便利, 又始终记得它'用什么换来的、在哪些情况下会失效', 并在那些失效的边缘地带主动设防这给了我一种看待"一切'默认好用'的规则、约定、工具行为"时的清醒:每当我顺手依赖一个'一直都对、特别方便'的规则时,要偶尔停下来问"这份方便, 是不是用某种严格性换来的?它在哪些场景下其实并不成立?我现在正在做的, 会不会恰好踩在那个被牺牲掉的场景上?"——对规则的便利充分利用, 但对它妥协掉的那部分边界心知肚明、主动防范, 而不是把'大多数时候都对'当成'永远都对';"认清每条便利规则背后的妥协与失效边界、并在边界处自己补上严格性",是用好工具、也是不被工具的便利反噬的关键认清类型系统的便利源于对健全性的妥协、允许不等于安全、风险藏在被牺牲掉的少数场景里——这,是我用一次 Dog 数组混进 Cat 的事故,换来的、关于 TypeScript、也关于如何看待规则的便利与其代价的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次顺手把一个具体类型的集合传给一个期望更宽类型的函数时,先想想"它会往里写吗?写了会不会破坏我在意的东西?",并给只读的场景加上 readonly,那我对着那只"凭空出现在 Dog 数组里的 Cat"折腾的大半天,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

我满心欢喜开启了 C# 的可空引用类型,以为从此再也不会有 NullReferenceException 了,结果线上还是被一个 NRE 打脸——因为编译器的非空承诺,根本管不到从 JSON 反序列化、外部接口这些边界进来的 null 的深度复盘

2026-6-3 3:46:14

技术教程

我给 AI Agent 配了一套能自动清理数据的工具,本想让它帮我打理琐事,结果有天它理解偏了,自主地把一批不该删的生产数据给删了,而整个过程没有任何一个环节需要我点头确认的深度复盘

2026-6-3 3:56:41

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索