Generic Classes & Interfaces
generic interface และ type alias
หัวข้อที่มีชื่อว่า “generic interface และ type alias”interface หรือ type alias รับ type parameter ได้เหมือน function เป๊ะ ตัวคลาสสิกคือ container ที่ type แล้ว:
interface Box<T> { value: T;}
const numBox: Box<number> = { value: 42 };const strBox: Box<string> = { value: "hi" };numBox.value.toFixed(2); // ✅ value is numbertype alias ทำงานเหมือนกัน และมักดีกว่าสำหรับ union และ function:
type Pair<A, B> = { first: A; second: B };type Result<T> = { ok: true; value: T } | { ok: false; error: string };
const r: Result<number> = { ok: true, value: 10 };Result<T> คือ generic discriminated union — หนึ่งใน pattern ที่มีประโยชน์ที่สุดใน TypeScript และเป็นสิ่งที่เราจะกลับมาพูดในบท error-handling
generic class: parameter fix ต่อ instance
หัวข้อที่มีชื่อว่า “generic class: parameter fix ต่อ instance”เมื่อ class เป็น generic type parameter ของตัวเองจะถูกเลือก ตอนที่คุณ construct instance และทุก method ของ instance นั้นใช้ร่วมกัน:
// @expect-error TS2345class Stack<T> { private items: T[] = [];
push(item: T): void { this.items.push(item); } pop(): T | undefined { return this.items.pop(); } peek(): T | undefined { return this.items[this.items.length - 1]; }}
const numbers = new Stack<number>();numbers.push(1);numbers.push("two"); // ❌ Argument of type 'string' is not assignable to 'number'const last = numbers.pop();// ^? number | undefined(last ไม่ใช่ top — ไฟล์ script-scope แชร์ global กับ DOM lib และ top ถูกใช้แล้วในความหมาย Window.top; module ที่มี import/export จะไม่ชน)
พอเขียน new Stack<number>() T ก็เป็น number ตลอดอายุของ object นั้น หลายครั้งคุณไม่ต้องเขียนด้วยซ้ำ — new Stack() แล้ว push(1) ครั้งแรก infer ไม่ได้ แต่หลาย design ให้ inference เติม T จาก argument ของ constructor
unknown ไม่ใช่ any เมื่อไม่มีอะไรให้ infer
หัวข้อที่มีชื่อว่า “unknown ไม่ใช่ any เมื่อไม่มีอะไรให้ infer”เมื่อไม่มีอะไรให้ T infer ได้เลย T จะไม่กลายเป็น error และก็ไม่กลายเป็น any ด้วย — มันกลายเป็น unknown new Stack() เฉย ๆ ให้ Stack<unknown>:
class Box<T> { private items: T[] = []; push(item: T): void { this.items.push(item); }}
const b = new Box();// ^? Box<unknown>b.push(1);b.push("two"); // ✅ also compiles — unknown accepts any value in parameter positionpush ทั้งสองด้านบน type-check ผ่าน และง่ายที่จะอ่านผิดว่า T เงียบ ๆ กลายเป็น any ไปแล้ว มันไม่ใช่ — ความต่างสำคัญมากในทุกที่อื่นที่ใช้ b unknown ใน ตำแหน่ง parameter ยอมรับทุกอย่าง ซึ่งเป็นเหตุผลที่ push ไม่บ่นเลยไม่ว่าคุณจะส่งอะไร แต่ unknown ในตำแหน่งที่คุณ อ่านออกมา ยังบังคับให้คุณ narrow ก่อนทำอะไรกับมัน เหมือน unknown ตัวอื่น ๆ any จะให้คุณทำได้ทั้งสองอย่าง — เขียนอะไรเข้าไปก็ได้ แล้วอ่านผลลัพธ์กลับมาเป็น type อะไรก็ได้โดยไม่ต้อง narrow unknown ให้แค่ครึ่งแรก:
// @expect-error TS18046class Box<T> { private items: T[] = []; push(item: T): void { this.items.push(item); } pop(): T | undefined { return this.items.pop(); }}
const b = new Box();b.push(1);const x = b.pop();// ^? unknown | undefinedx.toFixed(0); // ❌ 'x' is of type 'unknown' — you must narrow it first, unlike anyนั่นคือทางแก้จริงของตัวอย่างก่อนหน้า: อย่าปล่อยให้ T เป็นค่า default ของ compiler — เขียน new Stack<number>() ตรง ๆ หรือออกแบบ constructor ให้มี argument ที่ inference เติม T ได้ unknown คือ compiler ที่ซื่อสัตย์ว่า “ไม่รู้จริง ๆ” ไม่ใช่การลดระดับแบบเงียบ ๆ ไปเป็น “อะไรก็ได้”
type parameter ต่อ method
หัวข้อที่มีชื่อว่า “type parameter ต่อ method”type parameter ที่ประกาศบน method (ไม่ใช่บน class) จะถูกเลือกใหม่ทุกครั้งที่เรียก อิสระจาก parameter ของ class:
class Collection<T> { constructor(private items: T[]) {}
// T is the class parameter (fixed per instance) first(): T | undefined { return this.items[0]; }
// U is a method parameter (fresh per call) map<U>(fn: (item: T) => U): U[] { return this.items.map(fn); }}
const c = new Collection([1, 2, 3]); // T = numberconst lengths = c.map((n) => n.toString().length);// ^? number[] — U inferred as number, per this callconst labels = c.map((n) => `item-${n}`);// ^? string[] — U inferred as string, a different callT ถูกตัดสินครั้งเดียว (ตอน construct) ส่วน U ถูกตัดสินทุกครั้งที่เรียก map การรู้ว่า parameter ตัวไหนเป็นของ class และตัวไหนเป็นของ method คือกุญแจในการอ่าน signature ของ generic class
implement generic interface
หัวข้อที่มีชื่อว่า “implement generic interface”class จะ implement generic interface ได้โดย fix parameter หรือคงความ generic ไว้เอง:
interface Repository<T> { getById(id: string): T | undefined; save(entity: T): void;}
interface User { id: string; name: string;}
// fix the parameter to a concrete typeclass UserRepository implements Repository<User> { getById(id: string): User | undefined { /* ... */ return undefined; } save(entity: User): void { /* ... */ }}กลิ่น AI: type parameter fix ผิดระดับ
หัวข้อที่มีชื่อว่า “กลิ่น AI: type parameter fix ผิดระดับ”ตอนนี้คุณรู้แล้วว่า parameter ของ class คือต่อ instance และของ method คือต่อ call มีความผิดพลาดเฉพาะแบบหนึ่งที่โค้ด generate เจอบ่อยเกินคาด: เอา type parameter ไปวางผิดที่ระหว่างสองแบบนี้
กลิ่น 1 — ควรเป็น method parameter แต่ดันประกาศบน class คู่ T/U บน class บังคับให้ทุกการใช้ instance ต้องใช้ type เดียวกัน ทั้งที่แต่ละ call ตั้งใจให้เป็นอิสระจากกัน:
// @expect-error TS2322 TS2339class MapperWrong<T, U> { transform(items: T[], fn: (item: T) => U): U[] { return items.map(fn); }}
const m = new MapperWrong<number, string>();m.transform([1, 2, 3], (n) => n.toString());m.transform(["a", "b"], (s) => s.length); // ❌ T is locked to number for this instanceพอ m ถูก construct เป็น MapperWrong<number, string> แล้ว การเรียก .transform ครั้งต่อ ๆ ไปก็ติดกับ T = number ทั้งที่ “transform array นี้ด้วย function นี้” ไม่จำเป็นต้องจำ type ข้าม call เลย ทางแก้คือย้าย T กับ U ไปที่ method ที่มันควรอยู่:
class Mapper { transform<T, U>(items: T[], fn: (item: T) => U): U[] { return items.map(fn); }}
const m = new Mapper();m.transform([1, 2, 3], (n) => n.toString()); // T = number, U = string, this call onlym.transform(["a", "b"], (s) => s.length); // ✅ T = string, U = number, independent callกลิ่น 2 — ควรเป็น class parameter แต่ดันประกาศบน method ทิศทางนี้แอบซ่อนกว่า เพราะมันมัก compile ผ่านและยัง “ดู” เป็น generic:
class StackWrong { private items: any[] = []; push<U>(item: U): void { this.items.push(item); } pop<U>(): U | undefined { return this.items.pop(); }}
const sw = new StackWrong();sw.push("first");sw.push(42); // compiles — push<U> is a fresh, disconnected U every callconst claimed = sw.pop<string>(); // claims string — but the real top item is 42console.log(claimed!.toUpperCase()); // "trust me" — U isn't linked to what was actually pushed// @runclass StackWrong { private items: any[] = []; push<U>(item: U): void { this.items.push(item); } pop<U>(): U | undefined { return this.items.pop(); }}
const sw = new StackWrong();sw.push("first");sw.push(42);const claimed = sw.pop<string>();console.log(claimed!.toUpperCase());โค้ดนี้ compile ผ่านโดยไม่มี error เลย แล้วรันจริงพัง:
TypeError: claimed.toUpperCase is not a functionตรงนี้ items ถูก type เป็น any[] ข้างใน ดังนั้นมันไม่เคยแชร์ type กับ U ต่อ call ของ push หรือ pop จริง ๆ — U แต่ละตัวแค่ตกแต่ง เหมือนกลิ่น “ใช้ครั้งเดียว ไม่เชื่อมอะไร” จากบทก่อนหน้าเป๊ะ แค่กระจายเป็นสอง method ที่ดูเชื่อมกันแบบผิวเผินเพราะใช้ตัวอักษรร่วมกัน Stack<T> ตัวจริงจากต้นบทนี้คือทางแก้ — T ระดับ class ตัวเดียว ที่ push กับ pop ใช้ร่วมกัน ผูกสิ่งที่ใส่เข้าไปกับสิ่งที่ออกมาได้จริง
Variance: Stack<Dog> เข้าแทน Stack<Animal> ได้ไหม?
หัวข้อที่มีชื่อว่า “Variance: Stack<Dog> เข้าแทน Stack<Animal> ได้ไหม?”พอ type parameter ถูก fix ต่อ instance แล้ว คำถามใหม่ก็โผล่ขึ้นมาที่ไม่เคยมีกับ function ธรรมดา: ถ้า Dog เป็น subtype ของ Animal แล้ว Stack<Dog> เป็น subtype ของ Stack<Animal> ไหม? คำถามนี้ — variance — มีคำตอบต่างกันขึ้นกับ วิธีใช้ type parameter ข้างในของ generic type และคำตอบของ TypeScript ในบาง case ก็ unsound โดยตั้งใจ
method shorthand: bivariant และ unsound
หัวข้อที่มีชื่อว่า “method shorthand: bivariant และ unsound”assign Stack<Dog> ให้ตัวแปรที่ type เป็น Stack<Animal> โดยใช้ method shorthand push(item: T): void ธรรมดาจาก Stack ของบทนี้:
class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }
class StackMethod<T> { private items: T[] = []; push(item: T): void { this.items.push(item); } pop(): T | undefined { return this.items.pop(); }}
const dogs = new StackMethod<Dog>();const animals: StackMethod<Animal> = dogs; // allowed — no error at allanimals.push(new Cat()); // pushes a Cat into what is really the Dog stackโค้ดนี้ compile ผ่านโดยไม่มี error เลย animals type เป็น StackMethod<Animal> ดังนั้น push Cat (ซึ่งเป็น Animal) ดูโอเคสมบูรณ์ — แต่ animals กับ dogs คือ object เดียวกัน pop จาก stack Dog จริง แล้วได้ Cat กลับมา type เป็น Dog:
// @runclass Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }
class StackMethod<T> { private items: T[] = []; push(item: T): void { this.items.push(item); } pop(): T | undefined { return this.items.pop(); }}
const dogs = new StackMethod<Dog>();const animals: StackMethod<Animal> = dogs;animals.push(new Cat());const back = dogs.pop();console.log(back!.bark());TypeError: back.bark is not a functionนี่คือ unsoundness ที่รู้จักกันจริง ไม่ใช่ bug ที่คุณกดโดนบังเอิญ: method ที่เขียนด้วย shorthand syntax (push(item: T): void) ถูกตรวจแบบ bivariant — TypeScript ยอมให้ assign ได้ ทั้งสองทิศทาง Stack<Dog> ไป Stack<Animal> หรือกลับกัน เพราะการบังคับ contravariant แบบเข้มงวดตรงนี้ทำให้โค้ดจริงพังเยอะเกินไป (โดยเฉพาะ API ที่ใช้ callback เยอะ) ตอนที่เคยลองทำ strictFunctionTypes — flag ที่เข้มงวด check ตัวนี้ — ถูกตั้งใจให้ข้าม method shorthand ด้วยเหตุผลนี้
property function syntax: ถูกปฏิเสธถูกต้อง
หัวข้อที่มีชื่อว่า “property function syntax: ถูกปฏิเสธถูกต้อง”เขียน method เดียวกันแบบ property ที่ type เป็น function แทน shorthand แล้ว assign แบบเดียวกันจะถูกปฏิเสธ:
// @expect-error TS2322class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }
class StackProp<T> { private items: T[] = []; push: (item: T) => void = (item) => { this.items.push(item); }; pop(): T | undefined { return this.items.pop(); }}
const dogs = new StackProp<Dog>();const animals: StackProp<Animal> = dogs;animals.push(new Cat());error ระบุความไม่ตรงที่ type system ที่ sound ควรจับได้เป๊ะ:
error TS2322: Type 'StackProp<Dog>' is not assignable to type 'StackProp<Animal>'. Types of property 'push' are incompatible. Type '(item: Dog) => void' is not assignable to type '(item: Animal) => void'. Types of parameters 'item' and 'item' are incompatible. Property 'bark' is missing in type 'Animal' but required in type 'Dog'.class shape เดียวกัน ความตั้งใจเดียวกัน แต่ผลลัพธ์ต่างกัน — เพราะแค่ วิธีสะกด method เท่านั้น นี่คือหนึ่งในบทเรียน “อ่าน TypeScript” ที่คมที่สุดของภาษานี้: method ที่เขียนด้วย shorthand syntax บน class หรือ interface ถูกตรวจแบบผ่อนปรนกว่า signature เดียวกันเป๊ะที่เขียนเป็น property
in กับ out: TypeScript มี variance annotation จริง
หัวข้อที่มีชื่อว่า “in กับ out: TypeScript มี variance annotation จริง”ต่างจาก type system บางแบบที่คิดเรื่อง variance แบบซ่อนเร้นอย่างเดียว TypeScript (4.7+) ให้คุณ ประกาศ มันได้ และตรวจสิ่งที่คุณประกาศเทียบกับวิธีใช้ type parameter จริง:
// @expect-error TS2636interface Consumer<out T> { consume: (value: T) => void; // T is only ever consumed -- this is really contravariant}error TS2636: Type 'Consumer<sub-T>' is not assignable to type 'Consumer<super-T>' as implied by variance annotation. Types of property 'consume' are incompatible. Type '(value: sub-T) => void' is not assignable to type '(value: super-T) => void'. Types of parameters 'value' and 'value' are incompatible. Type 'super-T' is not assignable to type 'sub-T'.out T แปลว่า “T ปรากฏแค่ในตำแหน่ง output” (covariant — ทิศทางเดียวกับ type argument: Dog เข้า Dog ออก แปลว่า Producer<Dog> เข้าแทน Producer<Animal> ได้) in T แปลว่า “T ปรากฏแค่ในตำแหน่ง input” (contravariant — ทิศทางตรงข้าม) in out T แปลว่าทั้งคู่ คือ invariant — ไม่มีทิศไหน subtype ได้อย่างปลอดภัย คุณจะเจอศัพท์ชุดนี้เหมือนกันในระบบ variance ของภาษาอื่น: Kotlin กับ C# ใช้ keyword in/out เดียวกันเป๊ะ Java สะกดด้วย wildcard (? extends T สำหรับ covariant, ? super T สำหรับ contravariant) Flow ใช้ prefix +/- เวอร์ชันของ TypeScript แปลกอยู่จุดหนึ่งที่ควรพูดตรง ๆ: annotation เป็น optional และส่วนใหญ่เป็นตัวช่วยด้านเอกสารกับ performance สำหรับคนเขียน library — TypeScript คำนวณ variance แบบ structural อยู่แล้วไม่ว่าคุณจะ annotate หรือไม่ และ annotation ถูกตรวจในฐานะ assertion (นั่นคือสิ่งที่ทำให้เกิด TS2636 ข้างบน) ไม่ใช่คำขอที่เปลี่ยนสิ่งที่ checker บังคับใช้ที่อื่น
ประเด็นสุดท้ายสำคัญ: การ annotate Stack<out T> ไม่ได้เปลี่ยนว่า Stack<Dog> เข้าแทน Stack<Animal> ได้ไหม — กฎ structural จาก section นี้ตัดสินไปแล้วก่อนที่คุณจะเขียน in/out สักตัว และเพราะ annotation ถูกตรวจด้วยการวัด variance แบบเดียวกัน กับ assignability ธรรมดา มันเลยสืบทอด blind spot ของ method shorthand มาด้วยเป๊ะ:
// declaring `out T` on a class whose push is method shorthand -- compiles clean,// even though push(item: T) is genuinely used contravariantly. The bivariance// that lets Stack<Dog> assign to Stack<Animal> also fools the annotation checker.class StackOut<out T> { private items: T[] = []; push(item: T): void { this.items.push(item); } pop(): T | undefined { return this.items.pop(); }}เปลี่ยน push เป็น property-function syntax บน declaration เดียวกัน แล้ว TS2636 จะยิงทันที ด้วยเหตุผลเดียวกับที่การ assign เองถูกปฏิเสธใน section ก่อนหน้า in/out ใน TypeScript พูดอีกแบบคือ feature จริงที่คุณจะเจอใน error message และใน .d.ts ของ library — แต่มันแค่บันทึกและตรวจซ้ำ variance เดียวกับที่ compiler คำนวณอยู่แล้ว ไม่ได้เพิ่มกลไกบังคับใช้ใหม่ทับลงไป
Covariance: readonly array
หัวข้อที่มีชื่อว่า “Covariance: readonly array”array ที่อ่านได้อย่างเดียวปลอดภัยที่จะปฏิบัติแบบ covariant และ TypeScript ก็ยอมแบบนั้นเป๊ะ:
class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }
declare const dogs: readonly Dog[];const animals: readonly Animal[] = dogs; // ✅ covariant — readonly arrays allow this safelyไม่มี push ให้แอบยัด Cat เข้ามา ดังนั้นตรงนี้ไม่มี unsoundness เลย — ทุก Dog เป็น Animal ที่อ่านได้จริง
แบบเดียวกัน แต่ mutable: unsoundness ที่รู้จักและตั้งใจ
หัวข้อที่มีชื่อว่า “แบบเดียวกัน แต่ mutable: unsoundness ที่รู้จักและตั้งใจ”เอา readonly ออก แล้ว assign แบบเดียวกันก็ ยังถูกยอมรับ — array เป็น covariant ใน TypeScript แม้จะ mutable ก็ตาม ซึ่งเป็น unsoundness ที่รู้จักกันมานานเพื่อความเข้ากันได้กับโค้ด JavaScript:
class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }
declare const dogs: Dog[];const animals: Animal[] = dogs; // compiles — mutable arrays are covariant tooanimals.push(new Cat()); // pushes a Cat into the array dogs also points at// @runclass Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }
declare const dogsSrc: Dog[];const dogs: Dog[] = [new Dog()];const animals: Animal[] = dogs;animals.push(new Cat());console.log(dogs[dogs.length - 1].bark());TypeError: dogs[dogs.length - 1].bark is not a functionContravariance: parameter ของ callback
หัวข้อที่มีชื่อว่า “Contravariance: parameter ของ callback”function type เดี่ยว ๆ (ไม่ใช่ method) ถูกตรวจแบบ contravariant ในตำแหน่ง parameter ภายใต้ strictFunctionTypes — ทิศทาง ตรงข้าม กับ type argument และทิศทางนี้ sound callback ที่รับ type ที่กว้างกว่าที่ต้องการ ใช้แทนที่ที่ต้องการ type แคบกว่าได้อย่างปลอดภัย เพราะมันยังจัดการทุกค่าที่จะเข้ามาได้:
class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }
declare function forEachDog(cb: (d: Dog) => void): void;const handleAnimal = (a: Animal) => console.log(a.name);forEachDog(handleAnimal); // ✅ a callback that handles ANY Animal can certainly handle a Dogกลับทิศแล้ว fail ถูกต้อง — callback ที่รู้จักจัดการแค่ Dog ไว้ใจให้จัดการทุก Animal ไม่ได้:
// @expect-error TS2345class 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); // ❌ handleDog can't handle every Animal (e.g. a Cat)error TS2345: Argument of type '(d: Dog) => void' is not assignable to parameter of type '(a: Animal) => void'. Types of parameters 'd' and 'a' are incompatible. Property 'bark' is missing in type 'Animal' but required in type 'Dog'.Invariance: mutable container ควรปฏิเสธทั้งสองทิศ — TypeScript ไม่บังคับ
หัวข้อที่มีชื่อว่า “Invariance: mutable container ควรปฏิเสธทั้งสองทิศ — TypeScript ไม่บังคับ”ใน type system ที่มี invariance จริง (List<T> ของ Java เป็นตัวอย่าง) type parameter ของ mutable container จะ vary ไปทิศไหนอย่างปลอดภัยไม่ได้เลย: การอ่านต้องการ covariance การเขียนต้องการ contravariance และ container ที่ทำทั้งสองอย่างต้องการทั้งคู่ ซึ่งมีแค่ invariance — ไม่มีทิศไหน substitute ได้อย่างปลอดภัย — เท่านั้นที่รับประกันได้จริง TypeScript ไม่มีการบังคับใช้ความเป็น invariant สำหรับ mutable field ธรรมดา มันตรวจ property แบบ structural และ covariant ซึ่งเป็น unsoundness เดียวกับที่คุณเพิ่งเห็นใน mutable array และมัน corrupt state ผ่านฝั่งเขียนได้แบบเดียวกันเป๊ะ:
class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }
class Box<T> { value: T; constructor(value: T) { this.value = value; }}
const dogBox = new Box<Dog>(new Dog());const animalBox: Box<Animal> = dogBox; // TS allows this — Box<T> is not enforced invariantanimalBox.value = new Cat(); // writes THROUGH to the same object dogBox holdsInvariance TypeScript doesn't enforce: a write-through corruption
class Animal { name = "animal"; }class Dog extends Animal { bark() { return "woof"; } }class Cat extends Animal { meow() { return "meow"; } }
class Box<T> { value: T; constructor(value: T) { this.value = value; }}
const dogBox = new Box<Dog>(new Dog());const animalBox: Box<Animal> = dogBox; // TS allows this -- Box<T> is not enforced invariantanimalBox.value = new Cat(); // writes through the SAME object dogBox holdsconsole.log(dogBox.value.bark()); // dogBox.value is now a Cat -- crash กด Check types: ไม่มี diagnostic เลย กด Run: มันพังด้วย dogBox.value.bark is not a function เพราะ animalBox.value = new Cat() เขียนทับ Dog ที่ dogBox ถืออยู่จริง ๆ — dogBox กับ animalBox คือ object เดียวกันใน memory และการ assign ผ่านเข้าไปได้ด้วย type ที่กว้างและผ่อนปรนกว่า ภาษาที่มี invariance จริงสำหรับ mutable generic (Java, Array<T> ของ Kotlin) จะปฏิเสธการ assign dogBox ไป animalBox ตั้งแต่บรรทัดแรกเลย การตรวจแบบ structural ทีละ property ของ TypeScript ไม่มีแนวคิด “field นี้ถูกเขียนถึง ดังนั้นให้ปฏิบัติแบบ invariant” — มันแค่เช็คว่า Dog assignable ให้ Animal สำหรับ property นั้นแล้วก็จบ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ถาม 3 คำถามนี้กับ generic type ที่คุณกำลังอ่าน ตามลำดับ: (1) type parameter ถูก fix ต่อ instance (ระดับ class) หรือใหม่ต่อ call (ระดับ method)? (2) สำหรับแต่ละตำแหน่งที่ T ปรากฏ มันเป็นสิ่งที่คุณอ่าน (covariant ปลอดภัยที่จะกว้างขึ้น) สิ่งที่คุณเขียน (contravariant สำหรับ parameter ของ function เดี่ยว ๆ แต่ bivariant และ unsound สำหรับ parameter ของ method shorthand) หรือทั้งคู่ (invariant — แต่ TypeScript แทบไม่บังคับใช้จริงสำหรับ mutable field ธรรมดา)? (3) ถ้าตัวแปรสอง type generic เดียวกันแต่ type argument ต่างกัน ดันไป alias object เดียวกัน — ผ่านการ assign ที่ unsound แบบที่บทนี้โชว์ — สิ่งที่แย่ที่สุดที่ตัวหนึ่งทำกับอีกตัวได้คืออะไร?
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| การออกแบบ | ข้อดี | ต้นทุน |
|---|---|---|
| type parameter ระดับ class | type เดียวใช้ร่วมกันถูกต้องในทุก method ของ instance | เลือกผิดถ้าแต่ละ call ต้องการ type อิสระจากกันจริง ๆ — ดูกลิ่น 1 |
| type parameter ระดับ method | อิสระต่อ call ไม่มี coupling ข้าม call | เลือกผิดถ้า type ต้องคงอยู่ข้าม call บน instance เดียวกัน — ดูกลิ่น 2 (ตกแต่ง, unsound) |
method shorthand (f(x: T): void) | กระชับ และจำเป็นสำหรับการ override method แบบ OOP ปกติให้ทำงานได้เลย | bivariant — unsound สำหรับ mutable generic container แบบที่ StackMethod ในบทนี้โชว์ |
property function (f: (x: T) => void) | contravariant ถูกต้องภายใต้ strictFunctionTypes — จับ bug จริงได้ | verbose กว่า เสีย ergonomics บางอย่างของ OOP override |
readonly T[] / in/out annotation | บันทึกและบังคับใช้ทิศทางที่ปลอดภัยอย่างชัดเจน | แก้ design ที่ต้องทั้งอ่านและเขียนแบบ unsound ย้อนหลังไม่ได้ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”unknownใน generic ที่ infer ไม่ได้ แปลว่า class ไม่ปลอดภัยแล้ว เหมือนanyมันคือunknownไม่ใช่any— เขียนเข้าไป (ตำแหน่ง parameter) ไม่มีข้อจำกัด แต่อ่านกลับออกมายังต้อง narrow ก่อนใช้ ต่างจากany- variance ของ generic class เป็น property เดียวคงที่ของ class มันขึ้นกับวิธีที่แต่ละ member ใช้ type parameter ทีละตำแหน่ง และ — สำหรับ method — ขึ้นกับว่า member นั้นเขียนด้วย shorthand หรือ property-function syntax
Stack<T>shape เดียวกันเป๊ะ sound หรือ unsound ในไฟล์เดียวกันได้ ขึ้นกับว่าคุณเลือก syntax ไหนให้push - annotation
in/outของ TypeScript ทำให้ generic type ปลอดภัย มันแค่บันทึกและ assert variance ที่ checker คำนวณอยู่แล้ว และถูกตรวจกับ variance ที่วัดได้จริง — ซึ่งสืบทอด bivariance ของ method shorthand มาด้วย การ annotateout Tบน class ที่ push เป็น method shorthand compile ผ่านสะอาดทั้งที่ unsoundness ข้างใต้ยังอยู่ - mutable generic container ใน TypeScript เป็น invariant เหมือนใน Java มันเป็น structural covariant เหมือนทุกอย่างอื่นใน object model ของ TypeScript — ไม่มีอะไรหยุดคุณจาก assign
Box<Dog>ให้Box<Animal>แล้ว corrupt ตัวต้นฉบับผ่านฝั่งเขียนได้เลย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”สองคำถามจับความผิดพลาดส่วนใหญ่ของโค้ด generate เรื่อง type parameter ได้:
- type parameter อยู่บน declaration ที่ถูกต้องไหม? ถ้าทุกการเรียก method ต้องการ type ของตัวเองแบบอิสระ แต่ class ประกาศ parameter แทน ให้มองหา type argument ของ class ที่ถูก “ล็อก” ไว้แบบไม่เข้าท่าตอน construct สำหรับสิ่งที่ควร vary ไปตาม call
- type parameter ระดับ method ผูกกับสิ่งที่ instance เก็บไว้จริงไหม? ถ้า class เก็บข้อมูลเป็น
any[](หรือ representation ที่ไม่มี type/widen ไปแล้วแบบอื่น) ในขณะที่ method ประกาศ<U>ของตัวเอง type parameter นั้นก็แค่ตกแต่ง — มันให้ผู้เรียก อ้าง type ตอนขาออกโดยไม่มีอะไรตรวจคำอ้างนั้นกับสิ่งที่เก็บไว้จริง
🐛 หา bug ในโค้ดนี้
class Cache {private store: any[] = [];
set<T>(value: T): number { return this.store.push(value) - 1;}
get<T>(index: number): T { return this.store[index];}}
const cache = new Cache();const id = cache.set({ name: "Ada" });const later = cache.get<string[]>(id);console.log(later.join(", "));เฉลย
โค้ดนี้ compile ผ่านโดยไม่มี error เลย Cache เก็บทุกอย่างใน store: any[] ดังนั้น set<T> กับ get<T> ต่างประกาศ T อิสระของตัวเองต่อ call — ไม่มีอะไรผูกสิ่งที่ set เก็บไว้กับสิ่งที่ get ได้รับอนุญาตให้อ้างกลับ cache.set({ name: "Ada" }) เก็บ object; cache.get<string[]>(id) ก็ assert ว่าผู้เรียกรู้ว่าค่าที่เก็บไว้เป็น string[] และ compiler ไม่มีทางตรวจคำอ้างนั้นกับ any ที่เก็บไว้จริง later.join(", ") type-check ผ่านเพราะ string[] มี .join แต่ตอนรันจริง later คือ { name: "Ada" } มันเลยพังด้วย later.join is not a function type parameter ควรอยู่ที่ class (Cache<T>) fix ครั้งเดียวต่อ instance เพื่อบังคับให้ set กับ get ใช้ T เดียวกัน — นั่นคือ shape ของ Stack<T> จากต้นบท และเป็นสิ่งที่ type parameter ระดับ call บน store ที่ไม่มี type ร่วมกันไม่มีทางให้ได้