บทเรียน 64 · Frontend · Functional Programming with TypeScript

interface กับ type ต่างกันตรงไหน และ any ที่ฆ่า type safety

~20 นาที · ควรผ่าน บทเรียน 63 (type พื้นฐาน) มาก่อน

บทที่แล้วคุณติดป้าย type ให้ค่าเดี่ยว ๆ อย่าง string กับ number บทนี้เราจะติดป้ายให้ object ทั้งก้อน ซึ่ง TypeScript มีสองวิธีให้เลือก: interface กับ type
สไลด์บอกว่าสองอันนี้ "คล้ายกันมาก ต่างกันแค่ interface เปิดเพิ่ม property ทีหลังได้" — ผมรันจริงทั้งสองฝั่งแล้ว และจะให้ดูว่าความต่างข้อนั้นเป็นทั้งของดีและกับดักพร้อมกัน
ครึ่งหลังคือสองคำที่ปิดปาก compiler ได้: as กับ any
สไลด์เขียนว่า "any Kills type safety entirely" — ผมไม่อยากให้คุณเชื่อเพราะสไลด์เขียน ผมเลยเอาโค้ดชุดเดียวกันไปรันสองรอบ รอบหนึ่งใช้ any รอบหนึ่งใช้ unknown แล้วนับ error ที่ tsc ฟ้องมาให้ดูเป็นตัวเลข

🧰 ของที่ใช้ตรวจในบทนี้

# npx tsc --version
Version 7.0.2

# node --version
v24.14.0

ทุกกล่องที่เขียนว่า "ผลรัน" ในบทนี้มาจากการรันจริงบนเครื่องผม — ตรวจ type ด้วย npx tsc --noEmit --strict ไฟล์.ts และถ้ามีค่าที่พิมพ์ออกจอ คือ compile แล้วรันด้วย node จริง
เลข TSxxxx และข้อความ error ทุกบรรทัดก็อปมาจาก tsc ตัวจริง ไม่ได้พิมพ์เอง · คุณ copy โค้ดในกล่องไปรันซ้ำได้ทุกก้อน

1 · interface — บอกว่า object ต้องมีหน้าตายังไง

ในบทที่แล้ว เวลาเราติดป้าย type ให้ตัวแปร เราเขียนว่า const name: string = "Ann" — ป้ายคือคำเดียวสั้น ๆ
แต่พอค่าที่จะติดป้ายเป็น object ที่มีหลายช่อง เราต้องบอก compiler ให้ครบว่ามีช่องอะไรบ้าง ช่องละ type อะไร

interface คือการตั้งชื่อให้รูปร่างของ object หนึ่งแบบ แล้วเอาชื่อนั้นไปใช้ซ้ำได้ทั้งโปรเจกต์ · นี่คือโค้ดจากสไลด์หน้า 14 ตรง ๆ:

interface IStudent {
  firstName: string;
  lastName: string;
  grade: "GRADE 1" | "GRADE 2" | "GRADE 3" | "GRADE 4" | "GRADE 5" | "GRADE 6";
}

const sampleStudent: IStudent = {
  firstName: "John",
  lastName: "Doe",
  grade: "GRADE 1",
};
# npx tsc --noEmit --strict a1-basic.ts
# (ไม่มี output เลย = ผ่าน) EXIT=0

สังเกตบรรทัด grade — มันไม่ใช่ string เฉย ๆ แต่เป็นรายชื่อค่าที่อนุญาต คั่นด้วย |
แปลว่า grade: "GRADE 7" จะไม่ผ่าน ทั้งที่มันก็เป็น string เหมือนกัน — เดี๋ยวเราจะได้เห็น error ตัวจริงของกรณีนี้ในหัวข้อ 7 (ภารกิจ)

📌 ทำไมสไลด์ตั้งชื่อว่า IStudent ขึ้นต้นด้วย I

เป็นธรรมเนียมการตั้งชื่อ ไม่ใช่กฎของภาษา — I ย่อมาจาก Interface ติดมาจากยุค C# และ Java
TypeScript เองไม่ได้บังคับ และคู่มือทางการก็ไม่ได้แนะนำให้ใส่ ทีมสมัยใหม่ส่วนใหญ่เขียน Student เฉย ๆ
สังเกตว่าในสไลด์หน้า workshop (หน้า 21) ผู้สอนเองก็เขียน interface ShippingAddress โดยไม่มี I
สิ่งที่ต้องทำจริง: เลือกแบบไหนก็ได้ แล้วใช้ให้เหมือนกันทั้งโปรเจกต์ — ปนกันในไฟล์เดียวคือสิ่งที่แย่ที่สุด

2 · type — ตั้งชื่อเล่นให้ "type อะไรก็ได้" ไม่ใช่แค่ object

type ทำงานคนละมุมกับ interface นิดหน่อย
interface ถูกออกแบบมาเพื่อบรรยายรูปร่างของ object ส่วน type คือการตั้งชื่อเล่นให้ type อะไรก็ได้ — จะเป็น object, จะเป็นรายการค่าที่เลือกได้, จะเป็น array ที่ล็อกจำนวนช่อง ก็ได้หมด

โค้ดจากสไลด์หน้า 15 ตรง ๆ:

type PairTuple = [number, boolean, string];
type StudentIdType = number | string;
type ClassroomType = {
  name: string;
  capability: number;
};

บรรทัดที่ 3–6 (ClassroomType) คือสิ่งที่ interface ทำได้เหมือนกันเป๊ะ
แต่สองบรรทัดแรกทำไม่ได้ด้วย interface — ลองแล้วได้แบบนี้:

type — ผ่าน

type StudentId = number | string;

EXIT=0 ไม่มี error

interface — ไม่ผ่าน

interface IStudentId = number | string;
error TS1005: '{' expected.

error TS1005 อ่านแล้วงงมากสำหรับมือใหม่ — มันไม่ได้บอกว่า "interface ทำ union ไม่ได้" มันบอกแค่ว่า "ตรงนี้ควรจะเป็น {"
เพราะในสายตาของ parser คำว่า interface แปลว่า "ต่อไปนี้จะมีปีกกาเปิดแล้วนะ" การเจอ = จึงเป็นเรื่องผิดไวยากรณ์ตั้งแต่ยังไม่ทันคิดเรื่อง type

ลองอีกทางที่ดูมีเหตุผลกว่า — ใช้ extends ต่อจาก union ที่ตั้งชื่อไว้แล้ว:

type StudentId = number | string;

interface IStudentId extends StudentId {}
e3-iface-extends-union.ts(3,30): error TS2312: An interface can only extend an
object type or intersection of object types with statically known members.

อันนี้ตรงประเด็นกว่ามาก — "interface ต่อยอดได้จาก object type เท่านั้น"
เหตุผลก็คือชื่อของมันเลย: interface แปลว่า "หน้าตาที่ยื่นออกมาให้คนอื่นใช้" ซึ่งเป็นคำที่ใช้กับ object · ส่วน "เลข หรือ ข้อความ" ไม่มีหน้าตาให้บรรยาย มันคือทางเลือกสองทาง

🧰 เทียบให้เห็นภาพ — แบบฟอร์มราชการ กับ คำย่อในบันทึกช่วยจำ

interface = แบบฟอร์มเปล่า ที่ทางการออกให้
บนกระดาษมีช่องพิมพ์ไว้แล้วว่า "ชื่อ ____ นามสกุล ____ ชั้น ____" ใครจะกรอกก็ต้องกรอกให้ครบตามช่อง · ฟอร์มมีหน้าตาแน่นอนเสมอ

type = คำย่อที่คุณนิยามไว้เองในบันทึกช่วยจำ
คุณเขียนไว้หน้าแรกว่า "ในเล่มนี้ รหัสนักเรียน หมายถึงเลขหรือข้อความก็ได้"
มันไม่ใช่ฟอร์ม มันคือข้อตกลงเรื่องความหมายของคำ ซึ่งย่ออะไรก็ได้ — จะย่อฟอร์มทั้งใบ ย่อทางเลือกสองทาง หรือย่อ "ชุดสามอย่างเรียงกัน" ก็ได้

ที่ไม่เหมือนกัน: ฟอร์มราชการกับคำย่อในบันทึก อยู่คนละโลกกันชัดเจน แต่ interface กับ type ในงานจริงทับกันเกิน 90% — ถ้าสิ่งที่คุณจะบรรยายเป็น object ธรรมดา ทั้งสองอันเขียนได้เหมือนกัน ใช้แทนกันได้ และ compiler มองว่าเป็นของอย่างเดียวกัน
คำเปรียบเทียบนี้จึงช่วยแค่ตอบว่า "อันไหนทำอะไรได้บ้าง" ไม่ได้แปลว่า "สองอันนี้คนละเรื่องกัน" — เดี๋ยวหัวข้อ 3 จะพิสูจน์ว่ามันแทนกันได้จริง

🗣 ศัพท์ที่เพิ่งเรียนไป
interface
คำสั่งที่ตั้งชื่อให้ "รูปร่างของ object" หนึ่งแบบ · ต่อยอดด้วย extends · ถาม senior ได้ว่า “โปรเจกต์นี้ใช้ interface หรือ type เป็นหลัก และมีเหตุผลอะไรอยู่เบื้องหลัง” คำถามนี้ได้คำตอบเร็วกว่าถามว่า “อันไหนดีกว่ากัน” เพราะคำตอบขึ้นกับทีม ไม่ใช่ขึ้นกับภาษา
type (type alias)
การตั้งชื่อเล่นให้ type อะไรก็ได้ ไม่จำกัดว่าต้องเป็น object · คำว่า alias สำคัญมาก — มันไม่ได้สร้าง type ใหม่ แค่ตั้งชื่อให้ของที่มีอยู่ · พิมพ์บอก AI ได้ว่า “use a type alias for this union, not an interface”
union type (A | B)
“เป็นอย่างใดอย่างหนึ่ง” · number | string = เป็นเลขก็ได้ เป็นข้อความก็ได้ · ใช้กับรายชื่อค่าตายตัวได้ด้วย เช่น "home" | "work" · เรียกว่า “union” ไปเลยตอนคุยงาน จะสื่อกว่าพูดว่า “ตัวแปรที่เป็นได้หลายอย่าง”
tuple ([number, boolean, string])
array ที่ล็อกทั้งจำนวนช่องและ type ของแต่ละช่อง · ต่างจาก string[] ที่ยาวเท่าไหร่ก็ได้แต่ทุกช่องต้องเป็น string · บอก AI ว่า “this should be a tuple, not an array” เมื่อลำดับของช่องมีความหมาย

3 · ต่างกันจริง ๆ ตรงไหน — interface เปิดเพิ่ม property ทีหลังได้

สไลด์หน้า 16 พูดประโยคเดียวจบเลย: "the key distinction is that a type cannot be re-opened to add new properties"
แปลตรง ๆ คือ ประกาศ interface ชื่อเดิมซ้ำได้ TypeScript จะรวมให้เอง แต่ type ชื่อเดิมซ้ำไม่ได้

ผมรันทั้งสองฝั่งด้วยไฟล์ที่หน้าตาเหมือนกันเป๊ะ ต่างแค่คำแรกของบรรทัด:

interface — ประกาศซ้ำได้

interface Window2 {
  title: string;
}
interface Window2 {
  ts: number;
}

const w: Window2 = { title: "หน้าแรก", ts: 1 };
# ไม่มี output
EXIT=0

type — ประกาศซ้ำไม่ได้

type Window3 = {
  title: string;
};
type Window3 = {
  ts: number;
};
(1,6): error TS2300: Duplicate
identifier 'Window3'.
(4,6): error TS2300: Duplicate
identifier 'Window3'.
EXIT=1

สังเกตว่าฝั่ง type ฟ้อง สองบรรทัด ไม่ใช่บรรทัดเดียว — compiler ไม่รู้ว่าอันไหน "ตัวจริง" มันเลยชี้ทั้งคู่แล้วบอกว่าชนกัน

interface Config {
  title: string;
}
interface Config {
  ts: number;
}

const c: Config = { title: "หน้าแรก" };   // ใส่แค่ title

เมื่อกี้เราเห็นแล้วว่า interface ชื่อซ้ำประกาศได้
คราวนี้เราสร้าง object โดยใส่แค่ title ตัวเดียว — tsc --strict จะว่ายังไง?

a4-merge-miss.ts(8,7): error TS2741: Property 'ts' is missing in type
'{ title: string; }' but required in type 'Config'.
EXIT=1

ไม่ผ่าน — เพราะสอง interface ที่ชื่อเดียวกัน ถูกรวมเป็นก้อนเดียวตั้งแต่ตอน compile
Config จึงไม่ใช่ "อันแรกหรืออันหลัง" แต่เป็น { title: string; ts: number } ที่ต้องมีครบทั้งสองช่อง

นี่คือจุดที่ความสามารถข้อนี้กลายเป็นกับดัก — คุณเพิ่มช่องบังคับให้ของที่คนอื่นใช้อยู่ได้ โดยไปแตะไฟล์เขาแม้แต่บรรทัดเดียวก็ไม่ต้อง
โค้ดที่เคยผ่านเมื่อวาน วันนี้พังโดยที่ไฟล์นั้นไม่ได้ถูกแก้เลย

⚠️ "เปิดเพิ่มได้" ไม่ใช่ฟีเจอร์ที่มีไว้ให้คุณใช้ทุกวัน

ความสามารถนี้มีชื่อว่า declaration merging และมันถูกสร้างมาเพื่อ งานเฉพาะทางอย่างหนึ่ง: การต่อเติม type ของ library ที่คุณไม่ได้เขียนเอง

ตัวอย่างที่เจอจริงในงานเว็บ: คุณใช้ library ที่แอบแปะค่าไว้บน window คุณจึงประกาศ interface Window { myThing: string } เพิ่มเข้าไปในของเดิมของเบราว์เซอร์ เพื่อให้ window.myThing ไม่ขึ้นขีดแดง
นอกจากกรณีแบบนี้ การประกาศ interface ชื่อซ้ำในโค้ดของตัวเองเกือบทั้งหมดคือความผิดพลาด — คนอ่านจะไม่มีทางรู้ว่าต้องไปดู Config ตรงไหนบ้าง เพราะมันกระจายอยู่กี่ที่ก็ได้ และไม่มีอะไรเตือน
ส่วน type ที่ชื่อซ้ำไม่ได้ ไม่ได้แปลว่ามัน "ด้อยกว่า" — มันคือสิ่งที่ทำให้คุณมั่นใจได้ว่านิยามของชื่อนี้มีที่เดียว

แล้วเรื่อง "ต่อยอด" ล่ะ? interface ใช้ extends ส่วน type ใช้ & — สไลด์หน้า 16 วางสองอันนี้ไว้คู่กัน ผมลองรันแล้วสลับใช้แทนกันดู:

interface ITwentyFourGame extends IGameSession {
  streak: number;
  hasTimer: boolean;
}

type TwentyFourGame = {
  streak: number;
  hasTimer: boolean;
} & GameSession;

const a: ITwentyFourGame = { round: "R1", score: 0, streak: 2, hasTimer: true };
const b: TwentyFourGame  = { round: "R1", score: 0, streak: 2, hasTimer: true };

const c: ITwentyFourGame = b;   // เอาของฝั่ง type ไปใส่ตัวแปรฝั่ง interface
const d: TwentyFourGame  = a;   // และสลับกลับ
# npx tsc --noEmit --strict f1-extends.ts
EXIT=0   # ผ่านหมด ไม่มี error สักบรรทัด

สองบรรทัดล่างคือหัวใจ — เอาค่าที่ประกาศด้วย type ไปใส่ตัวแปรที่ประกาศด้วย interface ได้ และสลับกลับก็ได้
เพราะ TypeScript ตัดสินว่า "ใส่ได้ไหม" จากรูปร่างของค่า ไม่ได้ดูว่าใครประกาศด้วยคำสั่งอะไร

🧰 เทียบให้เห็นภาพ — กุญแจที่ไขได้เพราะร่องตรงกัน ไม่ใช่เพราะยี่ห้อตรงกัน

ลูกกุญแจจะไขแม่กุญแจได้หรือไม่ ขึ้นกับร่องหยักบนตัวกุญแจล้วน ๆ
ไม่มีใครถามว่ากุญแจดอกนี้โรงงานไหนทำ หรือใครเป็นคนสั่งทำ — ถ้าร่องตรง มันก็ไข

TypeScript ตรวจ type แบบเดียวกันเป๊ะ · เรียกว่า structural typing
ค่าที่มีช่อง round · score · streak · hasTimer ครบ ก็ใส่ได้ทั้งตัวแปรฝั่ง interface และฝั่ง type ตามที่ผลรันข้างบนยืนยันไว้แล้ว

ที่ไม่เหมือนกัน: กุญแจที่ร่องตรงกันโดยบังเอิญคือเรื่องที่แทบไม่เกิด แต่ในโค้ด object สองก้อนที่มีชื่อช่องตรงกันโดยบังเอิญเกิดบ่อยมาก
{ id: string; name: string } ของ "สินค้า" กับของ "ผู้ใช้" หน้าตาเหมือนกันเป๊ะ → TypeScript จะยอมให้เอาสินค้าไปใส่ช่องที่รับผู้ใช้ โดยไม่ฟ้องอะไรเลย
ถ้าเจอกรณีแบบนี้ในงานจริง แปลว่า type ของคุณยังไม่ได้บรรยายความต่างที่แท้จริง ออกมาเป็นรูปร่าง — ไม่ใช่ความผิดของ compiler

🗣 ศัพท์ที่เพิ่งเรียนไป
declaration merging
การที่ TypeScript รวม interface ชื่อเดียวกันหลายอันเข้าเป็นก้อนเดียว · มีไว้ต่อเติม type ของ library เป็นหลัก · ถาม senior ได้ว่า “ที่นี่มีการทำ declaration merging กับ type ของ library ตัวไหนอยู่บ้าง” เพราะมันคือของที่หาไม่เจอด้วยการไล่อ่านไฟล์เดียว
structural typing
ตัดสินความเข้ากันได้จากรูปร่างของค่า ไม่ใช่จากชื่อ type · ตรงข้ามคือ nominal typing ที่ใช้ใน Go และ Java ซึ่งดูชื่อเป็นหลัก · พิมพ์ถาม AI ได้ว่า “TypeScript is structurally typed — how do I make these two types not interchangeable?” (คำตอบที่มันจะให้คือเทคนิคชื่อ branded type)
extends กับ & (intersection)
สองทางที่ทำเรื่องเดียวกันคือ “เอาของเดิมมาบวกช่องใหม่” · extends ใช้กับ interface · & ใช้กับ type · เรียกว่า intersection ตอนอ่านโค้ดคนอื่น จะได้ไม่สับสนกับ | ที่แปลว่า “อย่างใดอย่างหนึ่ง”

4 · สรุปตารางเลือก — ใช้อันไหนดี

สิ่งที่อยากทำinterfacetypeผลรันที่ยืนยัน
บรรยายรูปร่าง object✅ ได้✅ ได้EXIT=0 ทั้งคู่
ตั้งชื่อให้ union (number | string)❌ ไม่ได้✅ ได้TS1005 / TS2312
ตั้งชื่อให้ tuple❌ ไม่ได้✅ ได้เหตุผลเดียวกับ union
ต่อยอดจากของเดิม✅ extends✅ &EXIT=0 ทั้งคู่ และสลับใช้แทนกันได้
ประกาศชื่อเดิมซ้ำแล้วรวมกันเอง✅ ได้ (ระวัง)❌ ไม่ได้TS2300 ฝั่ง type
📌 กฎง่าย ๆ ที่ใช้ได้จริงตั้งแต่วันนี้

· เป็น object ธรรมดา → ใช้อะไรก็ได้ แต่ให้ทั้งทีมใช้อันเดียวกัน
· ไม่ใช่ object (union · tuple · ชื่อเล่นของ string) → ต้องใช้ type ไม่มีทางเลือก
· กำลังจะต่อเติม type ของ library → ต้องใช้ interface ไม่มีทางเลือกเหมือนกัน
· นอกจากนั้น: อย่าเสียเวลาเถียงกัน ความต่างที่เหลือแทบไม่กระทบงานประจำวัน

5 · as (type assertion) — ปุ่มปิดปาก compiler ปุ่มที่หนึ่ง

บางครั้งคุณรู้เรื่องของค่านั้นมากกว่า compiler จริง ๆ เช่นคุณเพิ่งอ่านค่ามาจากที่ที่ TypeScript มองไม่เห็น
สไลด์หน้า 17 เขียนไว้ว่า "Have some information about the type of a value but can't know about"
คำสั่ง as คือวิธีบอก compiler ว่า "เชื่อฉันเถอะ ค่านี้เป็น type นี้"

แล้วสไลด์ก็ใส่กล่อง ⚠️ Warning: Asserting a sub-type that doesn't fit ไว้ข้าง ๆ พร้อมโค้ดตัวอย่าง — แต่ไม่ได้บอกว่ามันพังยังไง
ผมเลยเอาโค้ดชุดนั้นมารันจริง ทั้ง tsc และ node:

type GameSession = { round: string; score: number };
type TwentyFourGame = { streak: number; hasTimer: boolean } & GameSession;

const initGame = {
  round: "ROUND 1",
  score: 0,
} as TwentyFourGame;      // ← ขาด streak กับ hasTimer

console.log("compile ผ่านแล้ว initGame =", initGame);
console.log("streak =", initGame.streak);
console.log("streak + 1 =", initGame.streak + 1);
console.log("streak.toFixed(1) =", initGame.streak.toFixed(1));
# npx tsc --noEmit --strict a6-assert.ts
EXIT_TSC=0        # ← ผ่านฉลุย ไม่มีคำเตือนแม้แต่ตัวเดียว
# node a6-assert.js
compile ผ่านแล้ว initGame = { round: 'ROUND 1', score: 0 }
streak = undefined
streak + 1 = NaN
TypeError: Cannot read properties of undefined (reading 'toFixed')
⚠️ ไล่ดูสี่บรรทัดผลรัน มันแย่ลงทีละขั้นแบบพอดี ๆ

บรรทัดแรก — initGame พิมพ์ออกมาแล้วไม่มี streak อยู่จริง ๆ ทั้งที่ type บอกว่าต้องมี · as ไม่ได้เติมอะไรให้ มันไม่แปลงค่า ไม่สร้างช่องใหม่
บรรทัดที่สอง — streak เป็น undefined เงียบ ๆ
บรรทัดที่สาม — undefined + 1 ได้ NaN ซึ่ง ยังไม่ crash · ค่าเน่านี้ไหลต่อไปได้อีกไกล อาจไปโผล่บนหน้าจอผู้ใช้เป็นคำว่า NaN
บรรทัดที่สี่ — ถึงจะพังจริง และพังที่นี่ ไม่ใช่ที่ต้นเหตุ
นี่คือเหตุผลที่ bug จาก as หายาก: ที่ที่มันระเบิด กับที่ที่มันเริ่มผิด อยู่คนละที่กัน

แล้วถ้าไม่ใส่ as ล่ะ — เขียนตรง ๆ ว่าตัวแปรนี้เป็น TwentyFourGame:

ใส่ as — compiler เงียบ

const initGame = {
  round: "ROUND 1",
  score: 0,
} as TwentyFourGame;

// EXIT=0 · พังตอน runtime

ไม่ใส่ as — โดนจับทันที

const initGame: TwentyFourGame = {
  round: "ROUND 1",
  score: 0,
};

// EXIT=1 · TS2322
a7-noassert.ts(11,7): error TS2322: Type '{ round: string; score: number; }' is not
assignable to type 'TwentyFourGame'.
  Type '{ round: string; score: number; }' is missing the following properties
  from type '{ streak: number; hasTimer: boolean; }': streak, hasTimer

โค้ดสองฝั่งนี้ต่างกันแค่ตำแหน่งที่เขียนชื่อ type — ฝั่งซ้ายเขียนไว้ท้ายค่าด้วยคำว่า as ฝั่งขวาเขียนไว้หลังชื่อตัวแปรด้วย :
แต่ผลต่างกันคนละโลก: ฝั่งขวา compiler บอกชื่อช่องที่ขาดให้เลย (streak, hasTimer) ตั้งแต่ก่อนโปรแกรมได้เริ่มทำงาน

📌 as ไม่ได้ปล่อยผ่านทุกอย่างนะ — มันมีเพดานอยู่

ลอง assert ข้ามโลกดู const s = n as string เมื่อ n เป็นเลข:
error TS2352: Conversion of type 'number' to type 'string' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
tsc ปฏิเสธ เพราะสองอันนี้ไม่มีอะไรทับกันเลย
ทีนี้ดูประโยคสุดท้ายของ error ให้ดี — มันสอนวิธีแหกกฎให้เอง และถ้าทำตามนั้นจริง ๆ:
const s = n as unknown as string; → EXIT=0 ผ่านสบาย
แล้วรันจริง: TypeError: s.toUpperCase is not a function
บทเรียน: as ที่ผ่านได้ ไม่ได้แปลว่าถูก — แปลว่าคุณรับผิดชอบเอง ตั้งแต่วินาทีที่พิมพ์คำนั้น · สอง as ต่อกันคือสัญญาณว่ากำลังฝืนของแรง ๆ

🧰 เทียบให้เห็นภาพ — เซ็นรับรองสำเนาถูกต้องด้วยตัวเอง

คุณถ่ายเอกสารมาใบหนึ่งแล้วเซ็นกำกับว่า "สำเนาถูกต้อง"
เจ้าหน้าที่รับไว้ทันที ไม่เปิดดูเทียบกับต้นฉบับ เพราะระบบถือว่าลายเซ็นของคุณคือคำรับรอง
ถ้าเอกสารนั้นขาดไปหนึ่งหน้า ไม่มีใครรู้ตอนยื่น — รู้ตอนที่มีคนต้องใช้หน้านั้น ซึ่งอาจเป็นอีกหลายเดือนถัดมา และตอนนั้นคุณจำไม่ได้แล้วว่าเซ็นตอนไหน

as คือลายเซ็นนั้น · : TwentyFourGame คือการยื่นให้เจ้าหน้าที่ตรวจจริง ซึ่งจะทักตรงหน้าเคาน์เตอร์เลยว่าขาดหน้าไหน

ที่ไม่เหมือนกัน: การเซ็นรับรองสำเนาเท็จมีความผิดทางกฎหมาย คนจึงกลัวและระวังกันเอง
แต่ as ในโค้ดไม่มีบทลงโทษ ไม่มีแม้แต่ warning สีเหลือง — ผลรันข้างบนยืนยันแล้วว่า EXIT_TSC=0 เงียบสนิท
มันจึงถูกใช้พร่ำเพรื่อกว่าที่ควรมาก โดยเฉพาะตอนรีบ ๆ อยากให้ขีดแดงหายไปไว ๆ — และขีดแดงที่หายไปเพราะ as ไม่ได้แปลว่าปัญหาหายไป แปลว่าคุณเลื่อนมันไปตอน runtime

🗣 ศัพท์ที่เพิ่งเรียนไป
type assertion (as)
การบอก compiler ว่า “ค่านี้เป็น type นี้ ไม่ต้องตรวจ” · ไม่แปลงค่าจริงแม้แต่นิดเดียว ตอน runtime มันหายไปเฉย ๆ · ถาม senior ได้ว่า “ตรงนี้ทำไมต้อง as — มีทางเขียนให้ compiler ตรวจเองได้ไหม”
type annotation (: Type)
การขอให้ compiler ตรวจว่าค่าตรงกับ type ที่ประกาศไหม · คนละเรื่องกับ assertion ทั้งที่หน้าตาใกล้กัน · ใช้คำให้ถูกตอนคุยงาน — “annotation” = ให้ตรวจ · “assertion” = สั่งไม่ต้องตรวจ
TS2352 / double assertion
error ที่ขึ้นเมื่อ assert ข้ามไป type ที่ไม่ทับกันเลย · การเขียน as unknown as X เพื่อลอดผ่านเรียกว่า double assertion · เจอ as unknown as ในโค้ดเมื่อไหร่ ให้ถือเป็นธงแดง และไปหาว่าทำไมต้องฝืนขนาดนั้น
runtime error / compile-time error
compile-time = เจอตอนตรวจ ยังไม่ได้รัน (ถูกกว่า เร็วกว่า) · runtime = เจอตอนโปรแกรมทำงานอยู่ ซึ่งอาจเป็นตอนที่ผู้ใช้กำลังใช้ · พูดว่า “อันนี้เลื่อน error จาก compile-time ไป runtime” จะสื่อกว่าพูดว่า “มันไม่ปลอดภัย”

6 · any ฆ่า type safety — พิสูจน์ด้วยตัวเลข

สไลด์หน้า 18 มีข้อความอยู่กลางหน้าประโยคเดียว:
"any" Kills type safety entirely — should be use "unknown" for uncertain types
คำว่า entirely (ทั้งหมด) ฟังดูแรงมาก ผมเลยเอาไปวัดจริง ๆ ด้วยวิธีง่ายที่สุด: เขียนไฟล์สองไฟล์ที่เหมือนกันทุกตัวอักษร ยกเว้นคำเดียว แล้วนับ error ที่ได้

ไฟล์ที่ 1 — any

const data: any =
  JSON.parse('{"name":"Ann"}');

console.log(data.name);
console.log(data.profile.email);
console.log(data.toUpperCase());
data();
const n: number = data;
const s: string = data;

ไฟล์ที่ 2 — unknown

const data: unknown =
  JSON.parse('{"name":"Ann"}');

console.log(data.name);
console.log(data.profile.email);
console.log(data.toUpperCase());
data();
const n: number = data;
const s: string = data;

ผลรัน npx tsc --noEmit --strict ทีละไฟล์:

# ไฟล์ที่ 1 (any)
EXIT_ANY=0            # ← ศูนย์ error

# ไฟล์ที่ 2 (unknown)
b2-unknown.ts(3,13): error TS18046: 'data' is of type 'unknown'.
b2-unknown.ts(4,13): error TS18046: 'data' is of type 'unknown'.
b2-unknown.ts(5,13): error TS18046: 'data' is of type 'unknown'.
b2-unknown.ts(6,1):  error TS18046: 'data' is of type 'unknown'.
b2-unknown.ts(7,7):  error TS2322: Type 'unknown' is not assignable to type 'number'.
b2-unknown.ts(8,7):  error TS2322: Type 'unknown' is not assignable to type 'string'.
EXIT_UNKNOWN=1        # ← หก error
บรรทัดในไฟล์ตั้งใจทำอะไรanyunknown
data.nameอ่านช่องที่มีอยู่จริงผ่านTS18046
data.profile.emailอ่านช่องที่ไม่มีผ่านTS18046
data.toUpperCase()เรียก method ของ string ใส่ objectผ่านTS18046
data()เรียก object เหมือนเป็นฟังก์ชันผ่านTS18046
const n: number = dataยัด object ใส่ตัวแปรเลขผ่านTS2322
const s: string = dataยัด object ใส่ตัวแปรข้อความผ่านTS2322

หกต่อศูนย์ · คำว่า entirely ในสไลด์ไม่ได้พูดเกินจริงเลย
สังเกตว่ามันไม่ใช่ "any ตรวจน้อยลง" — มันไม่ตรวจอะไรเลยสักบรรทัด แม้แต่บรรทัดที่เรียก object เหมือนเป็นฟังก์ชัน ซึ่งผิดชัด ๆ ด้วยตาเปล่า

แล้วไฟล์ที่ 1 ที่ compile ผ่าน 100% นั้น เอาไปรันจริงได้ไหม — ลองแค่สองบรรทัดแรกพอ:

# node b3-any-run.js
บรรทัด 1  data.name          = Ann
TypeError: Cannot read properties of undefined (reading 'email')
function loadConfig(): any {
  return { retry: "3" };      // ← เป็น string นะ ไม่ใช่เลข
}

const cfg = loadConfig();
const retry: number = cfg.retry;   // เขียนไว้ชัดว่าเป็น number

console.log("retry =", retry);
console.log("retry + 1 =", retry + 1);
console.log("retry.toFixed(0) =", retry.toFixed(0));

ตัวแปร retry ประกาศไว้ว่าเป็น number ชัดเจน
tsc --strict จะฟ้องไหม? และถ้ารันได้ บรรทัด retry + 1 จะพิมพ์อะไรออกมา?

# npx tsc --noEmit --strict e1-spread.ts
EXIT_TSC=0

# node e1-spread.js
retry = 3
retry + 1 = 31
TypeError: retry.toFixed is not a function

31 ครับ ไม่ใช่ 4
เพราะค่าจริงคือข้อความ "3" และในภาษา JavaScript "3" + 1 คือการต่อข้อความ ได้ "31"

สิ่งที่น่ากลัวคือ บรรทัด const retry: number = ... โกหกอย่างสมบูรณ์แบบ — คุณเขียนคำว่า number ไว้กับตา แต่ในหน่วยความจำมันเป็น string
compiler ไม่ทัก เพราะ cfg เป็น any และ any ยัดใส่อะไรก็ได้โดยไม่ตรวจ

นี่คือความหมายจริงของคำว่า “any แพร่กระจาย” — ฟังก์ชันตัวเดียวที่คืน any ทำให้ตัวแปรปลายทางที่ติดป้ายไว้ดีแล้วเชื่อถือไม่ได้ไปด้วย
เปลี่ยน loadConfig(): any เป็น loadConfig(): unknown อย่างเดียว แล้วรันซ้ำ ได้ทันที:
e2-spread-unknown.ts(6,23): error TS18046: 'cfg' is of type 'unknown'.

แล้วใช้ unknown ยังไงให้ทำงานได้จริง

ถ้า unknown ห้ามแตะทุกอย่าง มันก็ดูไร้ประโยชน์ — แต่ประเด็นคือ มันบังคับให้คุณ "พิสูจน์ก่อนใช้" ไม่ใช่ห้ามใช้

const data: unknown = JSON.parse('{"name":"Ann"}');

if (typeof data === "object" && data !== null && "name" in data) {
  console.log("ผ่านด่านแล้ว name =", data.name);
} else {
  console.log("ข้อมูลไม่ตรงรูปที่คาดไว้ ไม่แตะมัน");
}

const bad: unknown = JSON.parse('123');   // คราวนี้ได้เลข ไม่ใช่ object

if (typeof bad === "object" && bad !== null && "name" in bad) {
  console.log("ผ่านด่านแล้ว name =", bad.name);
} else {
  console.log("ข้อมูลไม่ตรงรูปที่คาดไว้ ไม่แตะมัน");
}
# npx tsc --noEmit --strict b4-unknown-ok.ts
EXIT_TSC=0

# node b4-unknown-ok.js
ผ่านด่านแล้ว name = Ann
ข้อมูลไม่ตรงรูปที่คาดไว้ ไม่แตะมัน
EXIT_NODE=0
✅ สังเกตสามอย่างในผลรันนี้

① data.name ข้างใน if ไม่ error แล้ว ทั้งที่ข้างนอกเคย error — compiler ตามอ่านเงื่อนไขของคุณแล้วยอมรับว่าคุณพิสูจน์มาแล้ว
② เคสที่สอง JSON.parse('123') ได้เลข ไม่ใช่ object → เข้า else เรียบร้อย ไม่ crash
③ EXIT_NODE=0 — โปรแกรมจบปกติ ต่างจากทุกตัวอย่างของ any ในหัวข้อนี้ที่จบด้วย TypeError
ราคาที่จ่าย คือโค้ด if สามเงื่อนไข ซึ่งดูรุงรัง — แต่นั่นคืองานที่ต้องทำอยู่แล้วทุกครั้งที่รับข้อมูลจากข้างนอก any ไม่ได้ทำให้งานนี้หายไป มันแค่ทำให้คุณลืมว่ามีงานนี้อยู่

🧰 เทียบให้เห็นภาพ — พัสดุที่เขียนหน้ากล่องว่า "ของใช้ทั่วไป"

มีกล่องพัสดุมาส่งสองใบ:
· ใบแรกเขียนหน้ากล่องว่า "ของใช้ทั่วไป" — ยามอ่านแล้วโบกมือให้ผ่านเลย ไม่เปิดดู เพราะป้ายบอกว่าเป็นของธรรมดา
· ใบที่สองเขียนว่า "ไม่ทราบชนิดสิ่งของ" — ยามไม่ให้ผ่านจนกว่าจะเปิดดูข้างในก่อน

any คือกล่องใบแรก · unknown คือกล่องใบที่สอง
ทั้งสองใบข้างในเหมือนกันเป๊ะ — ต่างกันแค่คำที่เขียนไว้หน้ากล่อง และคำนั้นเปลี่ยนว่ามีใครตรวจหรือไม่

ที่ไม่เหมือนกัน: ยามที่ปล่อยกล่องผ่าน ยังมีโอกาสสังเกตเห็นความผิดปกติทีหลัง — กล่องหนักผิดปกติ มีเสียงดังข้างใน
แต่ compiler ที่เจอ any ไม่ได้ "ตรวจแบบหลวม ๆ" — มันเลิกตรวจไปเลยถาวร ตามที่ตารางหกแถวข้างบนพิสูจน์ไว้ทุกช่อง
และมันไม่ได้เลิกตรวจแค่กล่องใบนั้น — ทุกอย่างที่หยิบออกมาจากกล่องก็กลายเป็น any ตามไปด้วย ซึ่งคือสิ่งที่คุณเพิ่งเห็นในกล่องทายผล (retry + 1 = 31)

🗣 ศัพท์ที่เพิ่งเรียนไป
any
type ที่แปลว่า “เลิกตรวจตรงนี้” · เข้าได้ทุกอย่างและออกไปเป็นอะไรก็ได้ · ถาม senior ได้ว่า “มี any เหลืออยู่กี่ที่ในโปรเจกต์ และมีตัวไหนที่ตั้งใจให้อยู่บ้าง” คำถามนี้มักเปิดบทสนทนาเรื่องหนี้ทางเทคนิคได้ตรงจุดที่สุด
unknown
type ที่แปลว่า “ยังไม่รู้ว่าเป็นอะไร ห้ามแตะจนกว่าจะพิสูจน์” · เป็นตัวที่ควรใช้กับข้อมูลจากนอกโปรแกรม (API · JSON.parse · localStorage) · พิมพ์บอก AI ได้ว่า “type this as unknown and narrow it before use, don’t use any”
type narrowing
การไล่ตัดความเป็นไปได้ด้วย typeof · in · Array.isArray จนเหลือ type ที่ใช้งานได้ · คือวิธีที่ถูกต้องในการปลด unknown · ใช้คำว่า “narrow” ตอนคุยงาน เช่น “ต้อง narrow ก่อนถึงจะอ่าน field ได้”
type safety
การที่ compiler รับประกันได้ว่าค่าที่ไหลอยู่ในโปรแกรมตรงกับ type ที่ประกาศไว้ · ทั้ง as และ any คือการยกเลิกการรับประกันนั้น ต่างกันตรงที่ as ยกเลิกเป็นจุด ๆ ส่วน any ยกเลิกทั้งสายที่ค่านั้นไหลผ่าน

7 · ภารกิจ — สองเวิร์กช็อปจากสไลด์

🛠 ภารกิจที่ 1 — Design Interface (สไลด์หน้า 19–21)

โจทย์จากสไลด์: "คุณเพิ่งเข้าทีมที่ทำแพลตฟอร์ม e-commerce งานของคุณคือออกแบบ interface ของระบบ User Profile และ Shipping Address"

  1. สร้างไฟล์ workshop1.ts แล้วออกแบบเองก่อนดูเฉลย โดยต้องมีให้ครบ:
    • AddressLabel — บ้าน / ที่ทำงาน / อื่น ๆ · ถามตัวเองก่อนว่าอันนี้ต้องเป็น type หรือ interface
    • ShippingAddress — ชื่อผู้รับ · ที่อยู่ · จังหวัด · รหัสไปรษณีย์ · ประเทศ · เป็นที่อยู่หลักหรือไม่
    • UserProfile — รหัสผู้ใช้ · ชื่อ · อีเมล · เบอร์โทร (มีหรือไม่มีก็ได้) · รายการที่อยู่หลายอัน
  2. สร้างข้อมูลตัวอย่างหนึ่งก้อนที่ใช้ type พวกนี้ แล้วรัน npx tsc --noEmit --strict workshop1.ts ให้ EXIT=0
  3. เขียนเทสพิสูจน์ว่า type ทำงานจริง — ไม่ใช่แค่ "รันแล้วไม่ error" ให้เพิ่มสามบรรทัดที่ต้องพัง แล้วยืนยันว่า tsc ฟ้องครบทั้งสาม:
    • ใส่ label: "office" (คำที่ไม่ได้อยู่ในรายการ) → ต้องได้ TS2322
    • ลืมใส่ช่องที่บังคับไปหนึ่งช่อง → ต้องได้ TS2741
    • อ่าน phone.length ตรง ๆ โดยไม่เช็คก่อน → ต้องได้ TS18048
    ถ้าบรรทัดไหนไม่ฟ้อง แปลว่า type ตรงนั้นหลวมเกินไป ให้กลับไปแก้
  4. ลบสามบรรทัดนั้นออก แล้วยืนยันว่ากลับมา EXIT=0 อีกครั้ง

โค้ดจากสไลด์หน้า 21 ตรง ๆ:

type AddressLabel = "home" | "work" | "other";

interface ShippingAddress {
  label: AddressLabel;
  recipientName: string;
  street: string;
  city: string;
  province: string;
  postalCode: string;
  country: string;
  isDefault: boolean;
}

interface UserProfile {
  userId: string;
  fullName: string;
  email: string;
  phone?: string;
  addresses: ShippingAddress[];
}

สังเกตว่าสไลด์เลือกคนละแบบในไฟล์เดียวกัน อย่างมีเหตุผล:
· AddressLabel เป็น type เพราะมันคือ union — interface ทำไม่ได้ (หัวข้อ 2)
· อีกสองอันเป็น interface เพราะเป็น object ธรรมดา
· phone? มีเครื่องหมาย ? = optional ใส่หรือไม่ใส่ก็ได้
· ShippingAddress[] ที่ต่อท้ายด้วย [] = array ของ ShippingAddress กี่อันก็ได้

ผมเติมข้อมูลตัวอย่างเข้าไปแล้วรันจริง:

# npx tsc --noEmit --strict c1-workshop.ts
EXIT=0

# node c1-workshop.js  (console.log สองบรรทัด)
undefined
home

บรรทัด undefined คือ me.phone ที่ผมไม่ได้ใส่ — และ TypeScript ยอมให้ผ่าน เพราะมันเป็น optional

ส่วนสามบรรทัดที่ต้องพัง ผลรันจริงเป็นแบบนี้:
c2-workshop-bad.ts(10,3): error TS2322: Type '"office"' is not assignable to type 'AddressLabel'.
c4-missing.ts(9,7): error TS2741: Property 'isDefault' is missing in type '{ label: "home"; recipientName: string; }' but required in type 'ShippingAddress'.
c3-optional.ts(8,13): error TS18048: 'u.phone' is possibly 'undefined'.
ตัวสุดท้ายคือของแถมที่สำคัญที่สุด — ? ไม่ได้แปลว่า “ใส่ก็ได้ไม่ใส่ก็ได้แล้วจบ” มันแปลว่าทุกที่ที่อ่านค่านี้ ต้องเผื่อกรณีไม่มีเสมอ และ compiler จะบังคับให้คุณเผื่อ

🛠 ภารกิจที่ 2 — Spot the Type Error (สไลด์หน้า 22–23)

กติกาจากใบงานในสไลด์: อ่านโค้ดแต่ละข้อ ถ้ามี type error ให้วงบรรทัดที่ผิดแล้วเขียนวิธีแก้ ถ้าคิดว่าไม่มี error ให้เขียนว่า "No Error"
คำใบ้จากใบงานเอง: ไม่ใช่ทุกข้อที่มี error — มีข้อหนึ่งที่ถูกต้องสมบูรณ์
หมายเหตุตามตรง: สไลด์หน้า 23 โชว์ใบงานตัวจริงเป็นแค่ภาพหน้าจอ PDF 4 หน้าย่อ ๆ อ่านโจทย์แต่ละข้อไม่ออก · สี่ข้อข้างล่างจึงเขียนขึ้นใหม่ให้ตรงขอบเขตของบทนี้ โดยคงกติกาและคำใบ้ของใบงานไว้ · ถ้าคุณมีไฟล์ใบงานตัวจริง ให้ใช้โจทย์ในไฟล์นั้นแทน

  1. อ่านด้วยตาก่อน อย่าเพิ่งรัน · จดไว้ว่าข้อไหนพัง ข้อไหนไม่พัง และเดาเลข TSxxxx ด้วยถ้าเดาได้
  2. ก็อปทั้งสี่ข้อลงไฟล์ spot.ts ไฟล์เดียว แล้วรัน npx tsc --noEmit --strict spot.ts
  3. เทียบกับที่เดาไว้ · ข้อที่เดาผิดสำคัญกว่าข้อที่เดาถูก — จดไว้ว่าทำไมถึงเดาผิด
  4. เขียนเทสพิสูจน์: แก้แต่ละข้อให้ผ่าน ทีละข้อ แล้วรันซ้ำ ยืนยันว่า จำนวน error ลดลงทีละหนึ่งจนเหลือ EXIT=0 — ถ้าแก้ข้อหนึ่งแล้ว error หายไปสองอัน แปลว่าคุณยังไม่เข้าใจว่าอะไรผูกกับอะไร
// ข้อ 1
interface Product {
  id: string;
  discount?: number;
}
function show(p: Product) {
  return p.discount.toFixed(2);
}

// ข้อ 2
type Status = "draft" | "published";
type Status = "draft" | "published" | "archived";

// ข้อ 3
interface Cart {
  items: string[];
}
interface Cart {
  coupon: string;
}
const c: Cart = { items: [], coupon: "NEW10" };

// ข้อ 4
type Role = "admin" | "member";
const r: Role = "owner";
# npx tsc --noEmit --strict d1-spot.ts
d1-spot.ts(7,10):  error TS18048: 'p.discount' is possibly 'undefined'.
d1-spot.ts(11,6):  error TS2300: Duplicate identifier 'Status'.
d1-spot.ts(12,6):  error TS2300: Duplicate identifier 'Status'.
d1-spot.ts(25,7):  error TS2322: Type '"owner"' is not assignable to type 'Role'.
EXIT=1
ข้อผลเพราะอะไร · แก้ยังไง
1TS18048 discount? เป็น optional จึงอาจเป็น undefined
แก้: p.discount ?? 0 หรือเช็ค if (p.discount !== undefined) ก่อน
2TS2300 ×2 type ชื่อซ้ำไม่ได้ (หัวข้อ 3) · ฟ้องสองบรรทัดเพราะ compiler ไม่รู้ว่าอันไหนตัวจริง
แก้: ลบอันหนึ่งทิ้ง หรือเปลี่ยนชื่อ
3✅ No Error นี่คือข้อที่ใบงานบอกว่า “ถูกต้องสมบูรณ์” · interface ชื่อซ้ำรวมกันเอง → Cart ต้องมีทั้ง items และ coupon ซึ่งใส่มาครบแล้ว
4TS2322 "owner" ไม่อยู่ในรายการของ union
แก้: ใช้ค่าที่มีอยู่ หรือเพิ่ม "owner" เข้าไปใน Role

ข้อ 2 กับข้อ 3 คือคู่ที่สำคัญที่สุดของภารกิจนี้ — โค้ดสองข้อนี้โครงสร้างเหมือนกันเป๊ะ (ประกาศชื่อเดิมสองรอบ) ต่างกันแค่คำแรกของบรรทัด
ข้อหนึ่งพัง อีกข้อไม่พัง · นี่คือความต่างข้อเดียวที่แท้จริงระหว่าง interface กับ type ที่สไลด์หน้า 16 พูดถึง — และตอนนี้คุณเห็นมันเป็น error จริงกับตาแล้ว

💬 ติดตรงไหนถามได้

ถ้ารันแล้วได้ error ที่ไม่ตรงกับในบทนี้ อย่าเพิ่งคิดว่าตัวเองผิด — เอา error ทั้งบรรทัดพร้อมโค้ดมาถามได้เลย
เวอร์ชันของ TypeScript ที่ต่างกันขึ้นข้อความต่างกันได้จริง (บทนี้ตรวจด้วย 7.0.2) และผลรันบนเครื่องคุณคือความจริง ไม่ใช่ตัวหนังสือในหน้านี้

ทดสอบความเข้าใจ

1. ในไฟล์เดียวกัน ประกาศชื่อเดิมสองรอบ — interface Cart สองอัน กับ type Status สองอัน · tsc --strict จะว่ายังไง?

ผลรันจริงจากไฟล์ที่ต่างกันแค่คำแรกของบรรทัด:
· interface Window2 สองอัน → EXIT=0 ไม่มี output เลย
· type Window3 สองอัน → (1,6) และ (4,6) ได้ error TS2300: Duplicate identifier 'Window3'.
ตัวเลือกที่ 3 คือกับดักตัวจริง เพราะมันสลับข้างของคำตอบที่ถูก — ใครจำได้ว่า "มีฝั่งหนึ่งผ่าน" แต่จำไม่ได้ว่าฝั่งไหน จะเลือกข้อนี้พอ ๆ กับข้อที่ถูก
ตัวเลือกที่ 1 ผิดเพราะความสามารถนี้ (declaration merging) เป็นของ interface โดยเฉพาะ ไม่ใช่ของภาษาโดยรวม
ตัวเลือกที่ 4 ผิดชัดจากผลรัน — ฝั่ง type ได้ EXIT=1
จุดที่ต้องจำ: "เปิดเพิ่ม property ทีหลังได้" คือความต่างข้อเดียวที่แท้จริงระหว่างสองคำสั่งนี้ และมันเป็นของ interface เท่านั้น

2. const initGame = { round: "ROUND 1", score: 0 } as TwentyFourGame; โดยที่ TwentyFourGame ต้องมี streak กับ hasTimer ด้วย — เกิดอะไรขึ้น?

ผลรันจริง: EXIT_TSC=0 แล้วรันด้วย node ได้
initGame = { round: 'ROUND 1', score: 0 } · streak = undefined · streak + 1 = NaN · แล้วจึง TypeError: Cannot read properties of undefined (reading 'toFixed')
ตัวเลือกที่ 2 คือความเข้าใจผิดที่พบบ่อยที่สุดเรื่อง as — as ไม่แตะค่าจริงเลยแม้แต่นิดเดียว มันหายไปตอน compile บรรทัด console.log(initGame) พิสูจน์ไว้แล้วว่าไม่มีสองช่องนั้นอยู่จริง
ตัวเลือกที่ 1 ถูกสำหรับคนละกรณี — TS2352 ขึ้นตอน assert ข้ามไป type ที่ไม่ทับกันเลย เช่น 42 as string · แต่กรณีนี้เป็นการ assert ไปหาsub-type ซึ่ง TypeScript อนุญาต
ตัวเลือกที่ 4 คือสิ่งที่จะเกิดถ้าเอา as ออก แล้วเขียนเป็น const initGame: TwentyFourGame = {...} ซึ่งผลรันยืนยันว่าได้ TS2322 พร้อมชื่อช่องที่ขาด
จุดที่ต้องจำ: as ไม่ได้แปลงค่า มันแค่สั่งให้ compiler หยุดตรวจ — ทุกครั้งที่พิมพ์ as คุณกำลังย้ายความรับผิดชอบจาก compiler มาที่ตัวเอง

3. เอาโค้ด 6 บรรทัดชุดเดียวกัน (อ่านช่องที่ไม่มี · เรียกเป็นฟังก์ชัน · ยัดใส่ตัวแปรเลข ฯลฯ) ไปรันสองรอบ ต่างกันแค่ const data: any กับ const data: unknown — ได้ error กี่ตัว?

ผลรันจริง: EXIT_ANY=0 (ไม่มี output สักบรรทัด) กับ EXIT_UNKNOWN=1 ที่ฟ้อง 6 บรรทัดครบ
ตัวเลือกที่ 2 คือกับดักของคนที่คิดว่า any "ตรวจหลวม ๆ" — ความจริงคือมันเลิกตรวจทั้งหมด แม้แต่บรรทัด data() ที่เรียก object เหมือนเป็นฟังก์ชัน ซึ่งผิดชัดด้วยตาเปล่า ก็ยังผ่าน
ตัวเลือกที่ 3 ผิดตรงที่คิดว่า --strict ช่วยได้ — --strict คุมเรื่องอื่น (เช่น null) แต่ไม่มี flag ไหนทำให้ any ถูกตรวจ เพราะการไม่ตรวจคือความหมายของมัน
ตัวเลือกที่ 4 สลับข้างกันหมด · unknown คือตัวที่เข้มที่สุด ไม่ใช่หลวมที่สุด — มันรับค่าอะไรเข้ามาก็ได้ แต่ห้ามเอาไปทำอะไรจนกว่าจะพิสูจน์
จุดที่ต้องจำ: ข้อมูลที่มาจากนอกโปรแกรม (API · JSON.parse) ให้ติดป้าย unknown แล้ว narrow เอา — 6 error ที่มันฟ้อง คือ 6 จุดที่คุณต้องตัดสินใจอยู่ดี ต่างกันแค่จะตัดสินใจตอนนี้ หรือตอนที่ผู้ใช้เจอ TypeError

4. loadConfig() ประกาศว่าคืน any แต่คืน { retry: "3" } จริง ๆ · แล้วเขียน const retry: number = cfg.retry; · บรรทัด console.log(retry + 1) พิมพ์อะไร?

ผลรันจริง: EXIT_TSC=0 แล้ว node พิมพ์ retry = 3 · retry + 1 = 31 · แล้วจึงพัง TypeError: retry.toFixed is not a function
ตัวเลือกที่ 1 ผิดเพราะ cfg เป็น any จึงยัดใส่ตัวแปร number ได้โดยไม่มีใครทัก — ถ้าเปลี่ยนเป็น loadConfig(): unknown ตัวเลือกนี้จะกลายเป็นคำตอบที่ถูกทันที (ผลรันจริง: TS18046)
ตัวเลือกที่ 2 คือความเข้าใจผิดที่อันตรายที่สุด — ป้าย type ไม่แปลงค่า ไม่ว่าจะเขียนว่า number ชัดแค่ไหน ตอน runtime ก็ยังเป็น string อยู่ดี
ตัวเลือกที่ 3 ฟังดูมีเหตุผลที่สุดในสี่ข้อ และถูกในภาษาอื่น — แต่ JavaScript ตีความ + ที่มี string อยู่ข้างหนึ่งว่าเป็นการต่อข้อความ ไม่ใช่บวกเลข
เครื่องหมาย - ทำตรงกันข้ามเลย เพราะมันต่อข้อความไม่ได้ จึงแปลงเป็นเลขให้ — ผลรันจริงด้วย node: "3" + 1 ได้ 31 · "3" - 1 ได้ 2 · ส่วน NaN จะได้ก็ต่อเมื่อแปลงเป็นเลขไม่ได้จริง ๆ เช่น "abc" - 1
จุดที่ต้องจำ: any ตัวเดียวทำให้ตัวแปรที่ติดป้ายไว้ดีแล้วเชื่อถือไม่ได้ตามไปด้วย และผลที่ได้ไม่ใช่ error สวย ๆ แต่เป็นค่าที่ดูเหมือนใช้ได้ อย่าง 31 ที่ไหลต่อไปได้อีกไกล

อ้างอิง