Variance
ทบทวน: variance เป็นคำถาม ไม่ใช่สวิตช์
หัวข้อที่มีชื่อว่า “ทบทวน: variance เป็นคำถาม ไม่ใช่สวิตช์”Generic Classes & Interfaces แสดง surprise หลักไปแล้ว: Stack<Dog> เข้าแทน Stack<Animal> ได้เมื่อ push เขียนด้วย method shorthand (unsound — bivariant) และถูกปฏิเสธถูกต้องเมื่อ push เขียนเป็น property function (sound — contravariant) บทนั้นยังแสดงว่า readonly array กับ mutable array เป็น covariant ทั้งคู่ mutable object field ถูกตรวจแบบ structural covariant ทั้งที่กฎ invariant จริงควรปฏิเสธ และ TypeScript 4.7+ ให้คุณ ประกาศ variance ด้วย in/out ได้ บทนี้เอาคำตอบสี่แบบเดิม — covariant, contravariant, invariant, bivariant — มาตามดูใน type ที่คุณ import ใช้ทุกวันจริง ๆ: Promise, Map, Set, Array เทียบ ReadonlyArray และ function type ธรรมดา ไม่มี demo ข้างล่างซ้ำกับ Stack, Consumer, หรือ Box ของบทนั้น
variance สี่แบบ ภาพรวม
หัวข้อที่มีชื่อว่า “variance สี่แบบ ภาพรวม”| Variance | นิยาม | TypeScript ใช้ที่ไหน | ตัวอย่างบรรทัดเดียว |
|---|---|---|---|
| Covariant | Box<Dog> เป็น subtype ของ Box<Animal> — ทิศทางเดียวกับ type argument | readonly T[], ReadonlyArray<T>, Promise<T> และ (แบบ unsound) mutable T[], การอ่านของ Map/Set | declare const p: Promise<Dog>; const q: Promise<Animal> = p; |
| Contravariant | Handler<Animal> เป็น subtype ของ Handler<Dog> — ทิศตรงข้ามกับ type argument | parameter ของ function type เดี่ยว ภายใต้ strictFunctionTypes | type H<T> = (x: T) => void; declare const h: H<Animal>; const d: H<Dog> = h; |
| Invariant | ไม่มีทิศไหนปลอดภัย — กฎเดียวที่ sound สำหรับสิ่งที่คุณทั้งอ่านและเขียน | กฎที่ type parameter ของ mutable container ควร ทำตาม; TypeScript ไม่บังคับใช้กับ object property ธรรมดา | Box<Dog> ไม่ควร assignable ให้ Box<Animal> ถ้า Box มี .value ที่ set ได้ — แต่ TypeScript ยอมอยู่ดี |
| Bivariant | รับทั้งสองทิศ — unsoundness ที่รู้และตั้งใจ | method ที่เขียนด้วย syntax shorthand (push(item: T): void) รวมถึงทุก mutating method บน Map/Set ใน lib.d.ts | class C<T> { m(x: T) {} } ทำให้ C<Dog> กับ C<Animal> assign เข้าหากันได้อย่างอิสระ |
flowchart TB
q{"Where does T appear inside the generic type?"}
q -->|"only positions you READ"| co["Covariant
Box of Dog fits Box of Animal
(same direction as T)"]
q -->|"only positions you WRITE, under strictFunctionTypes"| contra["Contravariant
a Handler of Animal fits
where a Handler of Dog is needed
(opposite direction)"]
q -->|"BOTH read and written"| inv["Invariant -- the sound rule
neither direction should be safe
TypeScript mostly does not enforce this"]
q -->|"a method using SHORTHAND syntax"| biv["Bivariant -- known unsoundness
TypeScript allows BOTH directions"] Contravariance ขึ้นกับ strictFunctionTypes
หัวข้อที่มีชื่อว่า “Contravariance ขึ้นกับ strictFunctionTypes”แถว contravariant ข้างบนไม่ใช่ default behavior ของภาษาสำหรับทุกตำแหน่งรูปร่าง function — มันคือสิ่งที่ strictFunctionTypes (bundle มากับ --strict) เปิด เฉพาะ สำหรับ function type เดี่ยว (ไม่ใช่ method) assign handler ที่รับแค่ Dog ไปที่ตำแหน่งที่ต้องการ handler รับ Animal แล้วทิศทางถูกตรวจถูกต้อง ปฏิเสธมัน:
// @expect-error TS2322class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }
type Handler = (a: Animal) => void;const handleDog = (d: Dog) => console.log(d.bark());const h: Handler = handleDog;error TS2322: Type '(d: Dog) => void' is not assignable to type 'Handler'. Types of parameters 'd' and 'a' are incompatible. Property 'bark' is missing in type 'Animal' but required in type 'Dog'.อ่านแบบเดียวกับ error ซ้อนของ TypeScript ทุกอัน: บรรทัดบนสุดบอกความไม่ตรงกันทั้งหมด (function type สองอันเทียบกัน) บรรทัดถัดไปแคบลงมาที่ parameter ไหน ที่ไม่ตรง และบรรทัดสุดท้ายให้เหตุผลที่เป็นรูปธรรมระดับ structural — Dog ต้องการ method bark ที่ Animal ไม่ได้สัญญาไว้ สามบรรทัด แต่ละบรรทัดเจาะจงกว่าบรรทัดก่อนหน้าหนึ่งระดับ
ปิด strictFunctionTypes แล้วโค้ดชิ้นเดียวกันนี้ compile ผ่าน เพราะการตรวจ parameter ของ function type เดี่ยวตกกลับไปใช้กฎ bivariant แบบเดียวกับที่ method ได้รับอยู่แล้ว:
node_modules/typescript/bin/tsc --strict --strictFunctionTypes false --noEmit handler.ts(no output -- zero diagnostics)flag ตัวเดียวนี้คือความต่างทั้งหมดระหว่าง “contravariant” กับ “bivariant” สำหรับโค้ดชิ้นนี้เป๊ะ ปิดมันแล้ว ทุกตำแหน่งที่เป็น function type ในไฟล์ — รวมถึง parameter — จะถูกตรวจแบบผ่อนปรนเหมือนที่ method shorthand ได้รับอยู่เสมอ
in/out: ประกาศ variance แล้วให้ checker จับผิดคุณเอง
หัวข้อที่มีชื่อว่า “in/out: ประกาศ variance แล้วให้ checker จับผิดคุณเอง”TypeScript 4.7+ ให้ generic type ประกาศ variance ของมันด้วย in (contravariant — เฉพาะตำแหน่ง input) และ out (covariant — เฉพาะตำแหน่ง output) บน type parameter ประกาศทิศที่ตรงกับวิธีใช้ T จริง แล้วมันจะถูกยอมรับเงียบ ๆ:
interface Source<out T> { next(): T; // T appears only in an output position -- out is correct}ประกาศทิศผิด แล้ว checker ปฏิเสธ annotation เองเลย ไม่ขึ้นกับ call site ใด ๆ:
// @expect-error TS2636interface Emitter<in T> { emit(): T; // T appears only in an output position -- declaring in is a lie}error TS2636: Type 'Emitter<super-T>' is not assignable to type 'Emitter<sub-T>' as implied by variance annotation. The types returned by 'emit()' are incompatible between these types. Type 'super-T' is not assignable to type 'sub-T'.นี่คือภาพสะท้อนกลับของความผิดพลาด Consumer<out T> จากบทก่อนหน้า (ประกาศ out บน member ที่เป็น contravariant-only เท่านั้น): ที่นี่ in ถูกประกาศบน member ที่เป็น covariant-only เท่านั้น และ checker จับความผิดพลาดทิศตรงข้ามด้วยกลไก TS2636 เดียวกัน ไม่ว่าจะผิดทิศไหนโดยตั้งใจ ก็โดนจับก่อนที่ใครจะเขียน call site เลยด้วยซ้ำ
สิ่งนี้มีประโยชน์จริงสามข้อ: มัน บันทึก intent ตรง ๆ ในไฟล์ .d.ts ให้คนอ่านไม่ต้องไล่ทุก member เพื่อรู้ว่า type นี้ produce T อย่างเดียว; มันช่วย เร่งและคมชัดขึ้นในการตรวจ generic type แบบ recursive ที่การคำนวณ variance แบบ structural จากศูนย์ทุกครั้งที่ใช้มีต้นทุนสูง และ annotation ที่ชัดเจนช่วยตัดงานนั้นลง; และมัน จับการใช้ผิดโดยไม่ตั้งใจ ทันทีที่ shape ของ type เปลี่ยน — เพิ่ม member ตำแหน่ง input เข้าไปใน type ที่ annotate out T ไว้ แล้ว TS2636 จะยิงทันที ก่อนที่ call site ที่ไหนสักแห่งจะโผล่ปัญหาเดียวกันมาเป็น assignability error ที่งงยิ่งกว่า
สิ่งที่มันไม่ได้ทำคือทำให้ method-shorthand bivariance กลายเป็น sound annotation ถูกตรวจกับ variance ที่ compiler วัดได้จริง — และการวัดนั้นสืบทอด blind spot ของ shorthand มาเป๊ะเหมือนกับ assignability ธรรมดา:
// @runclass Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }
class Queue<out T> { private items: T[] = []; enqueue(item: T): void { this.items.push(item); } dequeue(): T | undefined { return this.items.shift(); }}
const dogQueue = new Queue<Dog>();const animalQueue: Queue<Animal> = dogQueue; // out T said this assignment is fineanimalQueue.enqueue(new Cat()); // the hole: enqueue is method shorthand, so bivariant underneathconsole.log(dogQueue.dequeue()!.bark());TypeError: dogQueue.dequeue(...).bark is not a functionQueue<out T> compile ผ่านด้วย diagnostic เป็นศูนย์ และ annotation ก็เป็นคำอธิบายถูกต้องของสิ่งที่ checker วัดได้จริงสำหรับ enqueue (method shorthand, bivariant ดังนั้นผ่าน test ที่ต้องเข้ากันกับ out ได้ทั้งสองทิศเหมือนที่ผ่าน assignability ธรรมดา) การประกาศ out T ไม่ได้เพิ่มการตรวจใหม่ทับลงไป — มันแค่ถามคำถามกับการวัดที่มีอยู่แล้ว และการวัดที่มีอยู่แล้วนั้นมี blind spot นี้อยู่ก่อนแล้ว เขียน enqueue ใหม่เป็น property function (enqueue: (item: T) => void) แล้ว TS2636 จะยิงทันที ด้วยเหตุผลเดียวกับที่การ assign Stack<Dog> ไป Stack<Animal> โดยตรงถูกปฏิเสธสำหรับ property function ในบทก่อนหน้า
Promise<T>: covariant อย่างปลอดภัยเพราะไม่มีทางเขียนกลับเข้าไป
หัวข้อที่มีชื่อว่า “Promise<T>: covariant อย่างปลอดภัยเพราะไม่มีทางเขียนกลับเข้าไป”Promise<T> produce T ออกมาอย่างเดียว — ไม่มี method ไหนให้ push type อื่นเข้าไปใน promise ที่สร้างไปแล้ว — ดังนั้น covariance ตรงนี้ sound เต็มร้อย ไม่ใช่ทางประนีประนอม:
class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }
declare const dogPromise: Promise<Dog>;const animalPromise: Promise<Animal> = dogPromise; // covariant -- safe, Promise only ever producesเทียบกับ Array<T> ที่ก็เป็น covariant ใน TypeScript เหมือนกัน แต่ ไม่ sound ในแบบเดียวกันเลย เพราะ array มีฝั่งเขียน (push) จริง ๆ ที่ caller ซึ่งถือ type กว้างกว่าเข้าถึงได้
Map<K, V> กับ Set<T>: covariant ตอนอ่าน unsound ตอนเขียน
หัวข้อที่มีชื่อว่า “Map<K, V> กับ Set<T>: covariant ตอนอ่าน unsound ตอนเขียน”การประกาศจริงของ Map ใน lib.es2015.collection.d.ts เขียนทุก mutating method ด้วย syntax shorthand:
set(key: K, value: V): this;method shorthand แปลว่า bivariant เป๊ะเหมือน push ของ Stack ในบทก่อนหน้า — ดังนั้น Map<string, Dog> ถูกยอมรับตรงที่ต้องการ Map<string, Animal> และ .set ของ reference ที่ widen แล้วรับ Animal อะไรก็ได้ รวมถึง Cat corrupt map ที่ตัวแปรทั้งสองชี้อยู่แบบเงียบ ๆ:
class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }
const dogsByName = new Map<string, Dog>([["rex", new Dog()]]);const animalsByName: Map<string, Animal> = dogsByName; // allowed -- Map's methods are shorthand, so bivariantanimalsByName.set("rex", new Cat()); // overwrites the real Dog entry with a CatMap<K, V> covariance: safe to read, unsound to write through set
class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }
const dogsByName = new Map<string, Dog>([["rex", new Dog()]]);const animalsByName: Map<string, Animal> = dogsByName; // allowed -- Map's methods are shorthand, so bivariantanimalsByName.set("rex", new Cat()); // overwrites the real Dog entry with a Catconsole.log(dogsByName.get("rex")!.bark()); // dogsByName's "rex" entry is now a Cat -- crash กด Check types: diagnostic เป็นศูนย์ — signature set เดียวกันที่ทำให้มันมีประโยชน์กับโค้ดทั่วไป (assign Map<string, Dog> ตรงที่ต้องการ Map<string, Animal> สะดวกจริง ๆ) ก็คือสิ่งที่ทำให้ถูกเอาไปใช้ผิดได้ กด Run: มันพังด้วย TypeError: dogsByName.get(...).bark is not a function เพราะ animalsByName.set("rex", new Cat()) เขียนทับเข้าไปที่ object Map เดียวกันเป๊ะที่ dogsByName ยังชี้อยู่ ถ้า set ถูกประกาศเป็น property function (set: (key: K, value: V) => this) แทน shorthand การ assign Map<string, Dog> ไป Map<string, Animal> จะถูกปฏิเสธตั้งแต่แรก แบบเดียวกับ StackProp ในบทก่อนหน้า — แต่มันไม่ใช่ และการไปแก้ declaration ของ standard library ไม่ใช่สิ่งที่ application code ทำได้
Set<T> มี shape เดียวกันและช่องโหว่เดียวกันเป๊ะ เพราะ add ก็ถูกประกาศแบบเดียวกัน:
class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }
declare const dogSet: Set<Dog>;const animalSet: Set<Animal> = dogSet; // covariant to readanimalSet.add(new Cat()); // Set.add is method shorthand too -- same hole as Map.setArray<T> เทียบ ReadonlyArray<T>
หัวข้อที่มีชื่อว่า “Array<T> เทียบ ReadonlyArray<T>”บทก่อนหน้าพิสูจน์ไปแล้วว่า mutable Array<T> เป็น covariant และ unsound (push Cat เข้าไปผ่าน reference Animal[] ที่ widen แล้ว) ReadonlyArray<T> ปิดช่องโหว่นี้ด้วยวิธีที่ต่างจาก in/out หรือ property-function syntax: มันไม่ได้ทำให้การตรวจ mutating method เข้มขึ้น มันเอา mutating method ออกจาก type ไปเลย ดังนั้นไม่มีอะไร bivariant เหลือให้เอาไปใช้ผิด:
// @expect-error TS2339class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }
declare const dogsRO: ReadonlyArray<Dog>;const animalsRO: ReadonlyArray<Animal> = dogsRO; // ✅ covariant -- nothing to write throughanimalsRO.push(new Dog()); // ❌ no push on ReadonlyArray at all -- the method doesn't exist to be bivarianterror TS2339: Property 'push' does not exist on type 'readonly Animal[]'.readonly T[] กับ ReadonlyArray<T> เป็นทั้ง covariant และ sound ด้วยเหตุผลเดียวกับ Promise<T>: ไม่มีฝั่งเขียนแปลว่าไม่มีทิศไหนถูกเอาไปใช้ผิดได้เลย ไม่ว่าฝั่งเขียนนั้นจะถูกตรวจแบบ contravariant, invariant หรือ (เหมือน mutating method จริง) bivariant ก็ตาม
Function type เทียบกัน: return covariant, parameter contravariant
หัวข้อที่มีชื่อว่า “Function type เทียบกัน: return covariant, parameter contravariant”Producer<T> (มี T ในตำแหน่ง return เท่านั้น) กับ Consumer<T> (มี T ในตำแหน่ง parameter เท่านั้น) วางเคียงกันเลย และแต่ละอันถูกตรวจในทิศที่ตำแหน่งของมันต้องการ:
class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }
type Producer<T> = () => T; // T only in an output position -- covarianttype Consumer<T> = (x: T) => void; // T only in an input position -- contravariant
declare const makeDog: Producer<Dog>;const makeAnimal: Producer<Animal> = makeDog; // ✅ covariant -- a Dog-maker IS an Animal-maker
declare const handleAnimal: Consumer<Animal>;const handleDog: Consumer<Dog> = handleAnimal; // ✅ contravariant -- an Animal-handler can certainly handle a Dogกลับด้าน assignment ของ Consumer แล้วมันพัง เพราะ handler ที่รับแค่ Dog ไว้ใจให้รับ Animal ทุกตัวไม่ได้:
// @expect-error TS2322class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }
type Consumer<T> = (x: T) => void;
declare const handleDog: Consumer<Dog>;const handleAnimal: Consumer<Animal> = handleDog; // ❌ reversed -- a Dog-only handler can't take every AnimalProducer กับ Consumer เคลื่อนไปทิศตรงข้ามกันจาก type argument เดียวกัน และทั้งสองทิศ sound ทั้งคู่ — ไม่เหมือนกรณี Map/Set/method-shorthand ข้างบน ตรงนี้ไม่มี bivariance เลยเพราะเป็น function type เดี่ยว ไม่ใช่ method
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ถามคำถามเดียวกับทุกตำแหน่งที่ T อยู่ ตามลำดับนี้: (1) นี่เป็น method ที่เขียนด้วย shorthand syntax ไหม? ถ้าใช่ หยุดเลย — มันเป็น bivariant เต็ม ๆ ไม่ว่าจะมี in/out หรือไม่ (2) ถ้าไม่ใช่ มีทางเขียนผ่านตำแหน่งนี้บ้างไหม (property ที่ set ได้ property-function หรือ mutating method ที่ไม่ใช่ shorthand)? ถ้าไม่มี มันปลอดภัยแบบ covariant เหมือน Promise<T> กับ ReadonlyArray<T> (3) ถ้ามีฝั่งเขียนและเป็น parameter ของ function เดี่ยว strictFunctionTypes ทำให้มันเป็น contravariant แบบ sound (4) ถ้ามีฝั่งเขียนและเป็น property ธรรมดาที่ set ได้ (ไม่ใช่ method) TypeScript ก็ยังตรวจแบบ covariant อยู่ดี — ตรงนี้แหละคือที่ที่ unsoundness เชิง structural จริง ๆ ที่คอร์สนี้พูดถึงซ้ำ ๆ อาศัยอยู่
flowchart TD
m{"Is this position a method
written with shorthand syntax?"}
m -->|"yes"| biv["Bivariant -- unsound,
regardless of in/out"]
m -->|"no"| w{"Is there any write side
at this position?"}
w -->|"no"| co["Covariant -- sound
(Promise, ReadonlyArray)"]
w -->|"yes, a function parameter"| contra["Contravariant under
strictFunctionTypes -- sound"]
w -->|"yes, a settable property"| unsound["Covariant anyway -- unsound
(the Map.set / plain field hole)"] ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
ออกแบบให้ covariant อย่างเดียว (Promise, ReadonlyArray, readonly T[]) | sound เต็มร้อย — widen ปลอดภัยเสมอเพราะไม่มีฝั่งเขียนให้เอาไปใช้ผิด | เสีย mutation ไปเลย caller ที่ต้องเขียนจริง ๆ ต้อง convert เป็น mutable type ก่อน |
| property-function method (contravariant) | จับ bug เรื่อง substitution จริงที่ call site ได้ เหมือน parameter ของ function เดี่ยว | verbose กว่า shorthand เสีย ergonomics ของ class override ไปบ้าง |
| method shorthand (bivariant) | กระชับ และจำเป็นสำหรับ OOP override ธรรมดาให้ typecheck ผ่านได้เลย | ทุก mutating method บน Map, Set, และ class ของคุณเองสืบทอดช่องโหว่นี้ — ไม่ใช่ edge case แต่เป็น default |
annotation in/out | บันทึก intent เร่งการตรวจ generic แบบ recursive ให้ไว และจับความขัดแย้งของ variance ทันทีที่ shape ของ type เปลี่ยน | ถูกตรวจกับ variance ที่วัดได้จริงแบบเดียวกับ assignability ธรรมดา — สืบทอด blind spot ของ shorthand แทนที่จะปิดมัน |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”Map<string, Dog>assignable ให้Map<string, Animal>เป็น convenience พิเศษสำหรับ dictionary มันคือกลไกเดียวกันเป๊ะกับ method-shorthand bivariance บน class ไหนก็ได้ — mutating method ของMapแค่บังเอิญถูกประกาศด้วย shorthand syntax ในlib.d.tsทางเลือกเดียวกับที่ทำให้pushของStackเป็น bivariant ในบทก่อนหน้าReadonlyArray<T>เป็น covariant เพราะ TypeScript มีเหตุผลพิเศษเกี่ยวกับ “read-only” ในฐานะ concept มันเป็น covariant ด้วยเหตุผล structural เดียวกับPromise<T>: ไม่มี method ไหนเลยที่ reference ที่กว้างกว่าจะเขียนกลับเข้าไปได้ ดังนั้นไม่มีอะไรให้กฎ variance ไหน — sound หรือ unsound — ทำผิดได้- ประกาศ
out Tบน class ทำให้ทุก member ของ class นั้น covariant annotation ถูกตรวจครั้งเดียว กับ type parameter ทั้งก้อน โดยใช้การวัดต่อ member ที่มีอยู่แล้วของ compiler — มันไม่ได้คำนวณหรือ override การวัดนั้นใหม่ ดังนั้น method shorthand ที่ bivariant ก็ยังวัดออกมาว่าเข้ากันได้กับการประกาศout(หรือin) อยู่ดี - ปิด
strictFunctionTypesกระทบแค่โค้ดที่ opt-out ชัดเจน มันเปลี่ยนวิธีตรวจตำแหน่งที่เป็น function type เดี่ยวทุกตำแหน่งในโปรแกรมที่ compile รวมถึงตำแหน่งลึก ๆ ใน library type declaration ที่คุณไม่ได้เขียนเอง ทำให้มันผ่อนปรนเท่ากับ method shorthand ที่เป็นแบบนั้นอยู่แล้ว
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”โค้ดที่ AI สร้างที่รับ container type กว้าง ๆ เป็น parameter แล้วเขียนค่าที่ดู “แคบกว่า” เข้าไปข้างใน compile ผ่านบ่อยกว่าที่ควรมาก เพราะ covariance ที่ทำให้ parameter type สะดวกต่อการส่งเข้ามาคือ covariance ตัวเดียวกันเป๊ะที่ทำให้การเขียนข้างในนั้น unsound เช็คทุกข้อนี้ก่อนเชื่อ function ที่ AI สร้าง:
-
parameter
Array<Base>ที่ได้ array ของ subtype แล้วเขียนเข้าไปข้างใน function ที่ type ให้รับArray<Base>แล้วบอกให้ “add item ใหม่” จะpushsibling type ผ่าน parameter ได้อย่างสบาย — ดูปริศนาข้างล่างสำหรับ shape แบบนี้เป๊ะ -
parameter
Record<string, Base>ที่มี write-through ช่องโหว่เดียวกัน แค่สะกดเป็น plain object type แทน array:class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }// AI smell: Record<string, Base> parameter, written into with a subtype map's valuesfunction tagAnimal(registry: Record<string, Animal>, key: string): void {registry[key] = new Cat(); // looks fine -- registry is "just" a string-to-Animal map}declare const dogRegistry: Record<string, Dog>;tagAnimal(dogRegistry, "rex"); // compiles: Record is a plain object type, covariant per property -
callback parameter ที่ type แคบกว่าที่ API สัญญาว่าจะเรียกจริง นี่คือความผิดพลาดแบบ
Consumer<Dog>-ตรงที่ต้องการ-Consumer<Animal>จาก section “Function type เทียบกัน” ข้างบน แค่มาในรูปโค้ดที่ AI สร้างแทนที่จะเป็นความผิดพลาดที่เขียนเอง — และTS2322/TS2345จับได้ก็ต่อเมื่อ callback นั้นไม่ได้ถูกห่อด้วย shorthand method ไปก่อนแล้ว -
ใช้
asเพื่อปิดปาก variance error แทนที่จะแก้จริง cast handler ที่แคบเกินไปให้เป็น function type ที่กว้างกว่าลบ diagnostic ออกโดยไม่ได้เปลี่ยนสิ่งที่ handler ทำได้จริง — ความเสี่ยงที่ถูกจับตอน compile time ย้ายไปอยู่ที่ runtime แบบไม่มีใครจับ:class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }declare function forEachAnimal(cb: (a: Animal) => void): void;const handleDog = (d: Dog) => console.log(d.bark());forEachAnimal(handleDog as (a: Animal) => void); // "as" doesn't fix it -- it hides itโค้ดนี้ compile ผ่านด้วย diagnostic เป็นศูนย์ — call เดียวกันที่เคยให้
TS2345ถูกต้องเมื่อสองบทก่อนหน้า ตอนนี้ไม่ให้อะไรเลย เพราะ cast บอก checker ให้หยุดตรวจ ไม่ใช่เพราะhandleDogปลอดภัยขึ้นจริง
🐛 หา bug ในโค้ดนี้
class Animal { sound() { return "..."; } }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }
function stockShelter(cages: Array<Animal>): void {cages.push(new Cat());}
const dogCages: Dog[] = [new Dog(), new Dog()];stockShelter(dogCages);console.log(dogCages[2].bark());เฉลย
โค้ดนี้ compile ผ่านโดยไม่มี error เลยแล้วพังตอน runtime ด้วย TypeError: dogCages[2].bark is not a function stockShelter ถูก type ให้รับ Array<Animal> ซึ่งอ่านเหมือน “แค่ให้ list ของ animal มา” — แต่ Dog[] assignable ให้ Array<Animal> แบบ covariant ที่ unsound เหมือน mutable array ทุกอัน ดังนั้น dogCages เอง (ไม่ใช่สำเนา) คือสิ่งที่ stockShelter ได้รับและ push Cat เข้าไป dogCages ถูก type แบบ static เป็น Dog[] ดังนั้น dogCages[2] ถูก type เป็น Dog และ .bark() ผ่าน type check — แต่ค่าที่อยู่จริงตอน runtime คือ Cat ที่ stockShelter เพิ่งเพิ่มเข้าไป ทางแก้คือหลักการเดียวกับ ReadonlyArray ข้างบน: ถ้า function แค่ต้องอ่าน animal type parameter เป็น ReadonlyArray<Animal> ให้ไม่มี push ให้ใช้ผิด; ถ้ามันต้องเพิ่ม animal ชนิดเฉพาะจริง ๆ มันควรรับชนิดนั้นตรง ๆ (Array<Animal> เป็น array ใหม่ที่มันเป็นเจ้าของเอง หรือ factory ที่ return animal ใหม่แทนการ mutate array ของ caller)