🧰 ของที่ใช้ตรวจในบทนี้
# 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 · สรุปตารางเลือก — ใช้อันไหนดี
| สิ่งที่อยากทำ | interface | type | ผลรันที่ยืนยัน |
|---|---|---|---|
| บรรยายรูปร่าง 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
| บรรทัดในไฟล์ | ตั้งใจทำอะไร | any | unknown |
|---|---|---|---|
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"
- สร้างไฟล์
workshop1.tsแล้วออกแบบเองก่อนดูเฉลย โดยต้องมีให้ครบ:AddressLabel— บ้าน / ที่ทำงาน / อื่น ๆ · ถามตัวเองก่อนว่าอันนี้ต้องเป็นtypeหรือinterfaceShippingAddress— ชื่อผู้รับ · ที่อยู่ · จังหวัด · รหัสไปรษณีย์ · ประเทศ · เป็นที่อยู่หลักหรือไม่UserProfile— รหัสผู้ใช้ · ชื่อ · อีเมล · เบอร์โทร (มีหรือไม่มีก็ได้) · รายการที่อยู่หลายอัน
- สร้างข้อมูลตัวอย่างหนึ่งก้อนที่ใช้ type พวกนี้ แล้วรัน
npx tsc --noEmit --strict workshop1.tsให้ EXIT=0 - เขียนเทสพิสูจน์ว่า type ทำงานจริง — ไม่ใช่แค่ "รันแล้วไม่ error"
ให้เพิ่มสามบรรทัดที่ต้องพัง แล้วยืนยันว่า tsc ฟ้องครบทั้งสาม:
- ใส่
label: "office"(คำที่ไม่ได้อยู่ในรายการ) → ต้องได้TS2322 - ลืมใส่ช่องที่บังคับไปหนึ่งช่อง → ต้องได้
TS2741 - อ่าน
phone.lengthตรง ๆ โดยไม่เช็คก่อน → ต้องได้TS18048
- ใส่
- ลบสามบรรทัดนั้นออก แล้วยืนยันว่ากลับมา 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 หน้าย่อ ๆ
อ่านโจทย์แต่ละข้อไม่ออก · สี่ข้อข้างล่างจึงเขียนขึ้นใหม่ให้ตรงขอบเขตของบทนี้
โดยคงกติกาและคำใบ้ของใบงานไว้ · ถ้าคุณมีไฟล์ใบงานตัวจริง ให้ใช้โจทย์ในไฟล์นั้นแทน
- อ่านด้วยตาก่อน อย่าเพิ่งรัน · จดไว้ว่าข้อไหนพัง ข้อไหนไม่พัง และเดาเลข
TSxxxxด้วยถ้าเดาได้ - ก็อปทั้งสี่ข้อลงไฟล์
spot.tsไฟล์เดียว แล้วรันnpx tsc --noEmit --strict spot.ts - เทียบกับที่เดาไว้ · ข้อที่เดาผิดสำคัญกว่าข้อที่เดาถูก — จดไว้ว่าทำไมถึงเดาผิด
- เขียนเทสพิสูจน์: แก้แต่ละข้อให้ผ่าน ทีละข้อ แล้วรันซ้ำ ยืนยันว่า
จำนวน 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
| ข้อ | ผล | เพราะอะไร · แก้ยังไง |
|---|---|---|
| 1 | TS18048 |
discount? เป็น optional จึงอาจเป็น undefined
แก้: p.discount ?? 0 หรือเช็ค if (p.discount !== undefined) ก่อน |
| 2 | TS2300 ×2 |
type ชื่อซ้ำไม่ได้ (หัวข้อ 3) · ฟ้องสองบรรทัดเพราะ compiler ไม่รู้ว่าอันไหนตัวจริง
แก้: ลบอันหนึ่งทิ้ง หรือเปลี่ยนชื่อ |
| 3 | ✅ No Error | นี่คือข้อที่ใบงานบอกว่า “ถูกต้องสมบูรณ์” · interface ชื่อซ้ำรวมกันเอง
→ Cart ต้องมีทั้ง items และ coupon ซึ่งใส่มาครบแล้ว |
| 4 | TS2322 |
"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 ที่ไหลต่อไปได้อีกไกล
อ้างอิง
- TypeScript Handbook — Differences Between Type Aliases and Interfaces —
อ่านตารางเทียบสองคอลัมน์ให้จบ แล้วเลื่อนลงไปอ่านย่อหน้าใต้ตาราง
ที่บอกว่าคู่มือทางการเองแนะนำให้ “ใช้
interfaceไปก่อนจนกว่าจะต้องใช้ความสามารถของtype” - TypeScript Handbook — Type Assertions —
อ่านสองย่อหน้าสุดท้ายของหัวข้อ ซึ่งอธิบายว่าทำไม TypeScript อนุญาตแค่ assert ไป type ที่
“กว้างกว่าหรือแคบกว่า” และทำไม
as unknown asถึงลอดผ่านได้ — ตรงกับTS2352ที่บทนี้รันให้ดู - TypeScript Handbook —
any— สั้นมากอ่านไม่ถึงนาที · สนใจคอมเมนต์ในโค้ดตัวอย่างที่เขียนว่า “Usinganydisables all further type checking” ซึ่งคือสิ่งที่ตารางหกแถวในหัวข้อ 6 วัดออกมาเป็นตัวเลขให้แล้ว - TypeScript Handbook — Narrowing —
อ่านหัวข้อย่อย typeof type guards และ The
inoperator narrowing สองอันนี้คือเครื่องมือที่ใช้ปลดunknownในตัวอย่างท้ายหัวข้อ 6 - TypeScript Handbook — Declaration Merging — อ่านแค่หัวข้อ Merging Interfaces พอ ส่วนที่เหลือเป็นเรื่อง namespace ซึ่งยังไม่ต้องใช้ · อ่านเพื่อยืนยันว่าฟีเจอร์นี้มีไว้ต่อเติม type ของ library เป็นหลัก ไม่ใช่ท่ามาตรฐานในโค้ดของตัวเอง