Why TypeScript?
bug ที่ TypeScript ถูกออกแบบมาเพื่อจับ
หัวข้อที่มีชื่อว่า “bug ที่ TypeScript ถูกออกแบบมาเพื่อจับ”นี่คือ JavaScript ธรรมดาที่ดูโอเคแต่พังตอน runtime:
function greet(user) { return "Hello, " + user.name.toUpperCase();}
greet({ nam: "Ada" }); // typo: `nam` not `name`// 💥 TypeError: Cannot read properties of undefined (reading 'toUpperCase')typo นี้จะไม่ถูกเจอจนกว่าบรรทัดนั้นจะรัน — อาจจะบน production, อาจจะเจอกับ user โชคร้ายคนหนึ่ง TypeScript ย้ายการค้นพบนั้นมาที่ compile time:
// @expect-error TS2561function greet(user: { name: string }) { return "Hello, " + user.name.toUpperCase();}
greet({ nam: "Ada" });// ❌ Error: Object literal may only specify known properties,// and 'nam' does not exist in type '{ name: string }'.คุณรู้ทันทีที่เขียน ใน editor ก่อนที่อะไรจะรัน การเปลี่ยนแปลงนั้น — จาก “พังทีหลัง ที่ไหนสักแห่ง” ไปเป็น “พังตอนนี้ ตรงนี้” — คือคุณค่าทั้งหมดของตัวเอง
compile time กับ runtime: เส้นแบ่งที่สำคัญ
หัวข้อที่มีชื่อว่า “compile time กับ runtime: เส้นแบ่งที่สำคัญ”TypeScript มีสอง phase ที่แยกกันสิ้นเชิง และการสับสนระหว่างสอง phase นี้คือต้นตอของความผิดพลาดของมือใหม่ส่วนใหญ่
flowchart LR ts[".ts source (type + logic)"] -->|tsc: type-check| check["Type checking (error รายงานตรงนี้)"] ts -->|tsc: emit| js[".js output (type ถูกลบ)"] js --> run["Runtime (ไม่มี type อยู่แล้ว)"]
- Compile time — type checker อ่าน annotation และ type ที่ infer ได้ของคุณ แล้วรายงาน error ความปลอดภัยทั้งหมดเกิดขึ้นตรงนี้
- Runtime — JavaScript ที่ emit ออกมารัน type หายไปหมดแล้ว เพราะถูกลบทิ้งตอน compile
นี่คือเหตุผลที่คุณทำแบบนี้ไม่ได้:
// @expect-error TS1005function isString(x: unknown) { return x is string; // ❌ not a thing — types don't exist at runtime}ตอน runtime ไม่มี type string ให้เช็ค — มีแต่ value ของ JavaScript การจะ narrow type ตอน runtime คุณต้องใช้ operation จริงของ JavaScript (typeof x === "string") ที่ compiler เข้าใจ เดี๋ยวเจาะลึกในบท narrowing
type erasure แบบเป็นรูปธรรม
หัวข้อที่มีชื่อว่า “type erasure แบบเป็นรูปธรรม”compile อันนี้:
interface User { name: string; age: number; }const u: User = { name: "Ada", age: 36 };function older(u: User): number { return u.age + 1; }JavaScript ที่ emit ออกมาคือ:
const u = { name: "Ada", age: 36 };function older(u) { return u.age + 1; }interface หายไปทั้งอัน annotation ถูกถอดออก type เพิ่ม runtime cost เท่ากับ ศูนย์ และไม่มีพฤติกรรมตอน runtime เลย — type คือบทสนทนาระหว่างคุณกับ compiler ที่จบก่อนโปรแกรมจะรัน
ต้นทุนที่ต้องยอมรับตามจริง
หัวข้อที่มีชื่อว่า “ต้นทุนที่ต้องยอมรับตามจริง”TypeScript ไม่ได้ฟรี และการแกล้งทำเป็นว่าฟรีก็ไม่ยุติธรรมกับคุณ:
- build step คุณต้องมี
tscหรือ bundler ที่ strip type ออก.tsธรรมดารันตรง ๆ ใน Node หรือ browser ไม่ได้ (แม้ว่า runtime หลายตัวจะเริ่มเพิ่มการ strip type เข้ามา) - learning curve type system เป็นภาษาในตัวเอง — คอร์สนี้ทั้งคอร์สมีอยู่ก็เพราะเหตุนี้
- ช่องว่างระหว่าง type กับ runtime compiler อาจ ผิด เรื่องความจริงตอน runtime ได้ —
any, cast ที่ผิด หรือ external data ที่ไม่ผ่านการ validate โกหก compiler ได้ type คือ model ไม่ใช่การการันตีเรื่องโลกภายนอก (โมดูล Practical Mastery พูดเรื่องนี้เป็นหลัก)
การแลกเปลี่ยนนี้เกือบจะคุ้มเสมอสำหรับอะไรก็ตามที่มากกว่า script ใช้แล้วทิ้ง: คุณจ่ายภาษีก้อนคงที่ แล้วได้ผลตอบแทนที่ทบต้นก้อนใหญ่กลับมาในรูปของ bug ที่ถูกจับ, การ refactor ที่ปลอดภัย และความฉลาดของ editor
TypeScript 7 กับ native compiler
หัวข้อที่มีชื่อว่า “TypeScript 7 กับ native compiler”คอร์สนี้เขียนอิง TypeScript 7 (กรกฎาคม 2026) compiler ถูก port จาก TypeScript ไปเป็น Go: ภาษาเดิม คำสั่ง tsc เดิม แต่การ type-check ตอนนี้รันแบบ native และขนานกัน (--checkers 4 เป็นค่าเริ่มต้น) ทำให้ full build เร็วขึ้นราว 8–12 เท่า และ “เวลาถึง error แรก” ใน editor ลดลงราว 10 เท่าบน codebase ใหญ่ ๆ — VS Code จาก 17.5s เหลือ 1.3s, Canva จาก 58s เหลือ 4.8s มีสองเรื่องที่กระทบตอนเรียน:
strictเปิดเป็นค่าเริ่มต้น ตั้งแต่ 6.0tsconfig.jsonเปล่า ๆ ก็หมายถึงnoImplicitAny,strictNullChecksและเพื่อน ๆ เปิดหมดแล้ว — ทุกตัวอย่างในคอร์สนี้ตั้งสมมติฐานแบบนั้น- ทางหนีเก่า ๆ หายไปแล้ว
target: "es5",moduleResolution: "node",baseUrlและesModuleInterop: falseถูก deprecate ใน 6.0 และถอดออกใน 7.0 โมดูล Compiler & Tooling อธิบายวิธี migrate
ถ้าคุณเรียน TypeScript มาจากยุค 4.x หรือ 5.x type system ที่รู้จักยังเหมือนเดิม สิ่งที่เปลี่ยนคือความเร็วและค่าเริ่มต้น บทเรียนจะแปะเวอร์ชันที่ feature ใหม่ ๆ เข้ามา (เช่น “5.5”) ให้รู้ว่าอะไรเพิ่งมา
as กับ ! เป็น assertion ไม่ใช่การแปลง
หัวข้อที่มีชื่อว่า “as กับ ! เป็น assertion ไม่ใช่การแปลง”ทุก value ในบทนี้ที่ผ่านมาถูกเช็คแบบ ซื่อสัตย์ — compiler ได้ type มาจากหลักฐานจริง (literal, return type, parameter ที่ประกาศไว้) as กับ ! ต่างออกไป: มันคือคุณ บอก compiler เรื่องบางอย่างที่มันไม่ได้ derive เอง โดยไม่มีการเช็คตอน runtime รองรับคำกล่าวอ้างนั้นเลย compiler เชื่อคุณเต็มที่ และ “เชื่อ” ตรงนี้แปลว่า “เลิกเช็ค” ไม่ใช่ “ตรวจสอบแล้วยืนยัน”
const raw: string = "42";const n = raw as unknown as number; // "trust me, this is a number"console.log(n.toFixed(2));โค้ดนี้ compile ผ่านโดย ไม่มี error เลย raw ตอน runtime ยังเป็น string "42" เหมือนเดิม — as ไม่ได้รัน code ใด ๆ มันแค่เปลี่ยนสิ่งที่ compiler เชื่อเกี่ยวกับ expression นั้นนับจากจุดนั้นไป กด Run:
A non-null assertion on a value that actually is null
// @runfunction find(id: string): { id: string; name: string } | undefined { return undefined; // pretend this looked it up and found nothing}
const user = find("1")!; // "trust me, this exists" — !, not a checkconsole.log(user.name.toUpperCase()); find("1") คืน undefined จริง ๆ ตรงนี้ — ! ไม่ได้เปลี่ยนความจริงข้อนั้น มันแค่บอก compiler ให้เลิกกังวลเรื่องนี้ โปรแกรม compile ผ่านสะอาดแล้ว throw TypeError: Cannot read properties of undefined (reading 'name') ทันทีที่รัน ตรงกับ crash ที่ strictNullChecks มีไว้ป้องกันพอดี เหมือน as: assertion คือคำกล่าวอ้างเกี่ยวกับ value ไม่ใช่การแปลง value นั้น
@ts-ignore กับ @ts-expect-error
หัวข้อที่มีชื่อว่า “@ts-ignore กับ @ts-expect-error”ทั้งสอง comment ระงับ error บรรทัดถัดไปเหมือนกัน — แต่พังคนละทิศทางเมื่อ code รอบ ๆ เปลี่ยนไปทีหลัง ซึ่งเป็นคุณสมบัติเดียวที่สำคัญใน codebase จริง
// @ts-ignoreconst total: number = "not a number"; // a real, live TS2322 — now silenced foreverconsole.log(total.toFixed(2));โค้ดนี้ compile ผ่านโดย ไม่มี error รายงานเลย — @ts-ignore กลืน error ทุกอย่างในบรรทัดถัดไปแบบไม่มีเงื่อนไข ดังนั้น bug จริง (total เป็น string) หายไปจาก output ของ compiler แล้วไปพังตอน runtime แทน: TypeError: total.toFixed is not a function
// @expect-error TS2578// @ts-expect-errorconst total: number = 1 + 1; // no error here — the suppression is now stale@ts-expect-error ให้คำสัญญา ตรงข้าม: “บรรทัดถัดไปมี error และถ้ามันเลิกมี error เมื่อไร ให้ build fail” ตรงนี้บรรทัดที่มันคุ้มครองไม่มีปัญหาอะไร (1 + 1 เป็น number จริง ๆ) ดังนั้นตัว directive เองกลายเป็น error: TS2578: Unused '@ts-expect-error' directive นั่นคือประเด็นทั้งหมด — ถ้า bug ที่ comment นี้เคยซ่อนไว้ถูกแก้ที่อื่น (value เลิกผิด) @ts-expect-error จะบอกคุณดัง ๆ แทนที่จะเงียบตลอดไปแบบ @ts-ignore
declare: type ที่ไม่มี implementation และไม่ emit อะไรเลย
หัวข้อที่มีชื่อว่า “declare: type ที่ไม่มี implementation และไม่ emit อะไรเลย”บางครั้ง value หนึ่งมีอยู่จริงตอน runtime ผ่านทางที่ compiler มองไม่เห็น — script tag ที่โหลดก่อน bundle ของคุณ, global ที่ test runner ฉีดเข้ามา, module ที่เขียนเป็น JavaScript ธรรมดาไม่มี type declare อธิบาย type ของ value นั้นโดยไม่ให้ (หรือเช็ค) implementation:
declare const BUILD_VERSION: string;declare function legacyAlert(msg: string): void;declare module "my-untyped-lib" { export function doThing(): void;}
console.log(BUILD_VERSION);legacyAlert("hi");โค้ดนี้ compile ผ่านโดยไม่มี error เลย และ — เพราะ declare แค่อธิบาย มันไม่เคยนิยาม — มัน emit ไม่มีอะไรเลย การ compile ตัวอย่างข้างบนด้วย tsc --outDir ได้ผลลัพธ์เป๊ะ ๆ นี้:
"use strict";console.log(BUILD_VERSION);legacyAlert("hi");statement declare ทุกตัวหายไปหมด เหลือแต่ code ที่ ใช้ ชื่อเหล่านั้น นี่คือ erasure แบบเดียวกับที่บทนี้เปิดเรื่องไว้ แค่ในระดับทั้ง declaration แทนที่จะเป็นแค่ annotation — และนี่คือเหตุผลที่ .d.ts มีอยู่: ไฟล์ทั้งไฟล์ที่มีแต่ statement รูปแบบ declare compile ออกมาเป็นความว่างเปล่า จึงส่งไปพร้อมกับ JavaScript จริงของ library เพื่ออธิบาย shape ของมันเท่านั้น
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกอย่างในบทนี้คือไอเดียเดียวที่ถูกใช้สองครั้ง: compiler รู้แค่สิ่งที่มัน derive ได้ และลบทุกอย่างที่มันรู้ทิ้งก่อนโปรแกรมจะรัน as/! คือคุณฉีดข้อเท็จจริงที่มันไม่ได้ derive เอง (และตรวจสอบไม่ได้); declare คือคุณฉีด declaration ทั้งอันที่มันไม่ได้ derive เอง (และเช็ค implementation กับมันไม่ได้) ทั้งคู่ compile ออกมาเป็นความว่างเปล่าและทั้งคู่เชื่อคุณเต็มที่ — นี่คือเหตุผลที่ทั้งสองคือจุดแรกที่ควรดูเมื่อ code ที่ generate มาหรือไม่คุ้นเคย compile ผ่านแต่ยังพังอยู่
flowchart LR a["as / ! (a claim about ONE value)"] --> trust["Compiler trusts it, checks nothing further"] d["declare (a claim about a whole binding)"] --> trust trust --> risk["Wrong claim survives to runtime unchanged"]
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| เครื่องมือ | ข้อดี | ต้นทุน |
|---|---|---|
as / as unknown as | หนี type error ได้ทันที ไม่มี runtime cost | ไม่มีการตรวจสอบเลย — คำกล่าวอ้างที่ผิด compile ผ่านสะอาดแล้วพังทีหลัง มักไกลจากจุด cast |
! (non-null assertion) | สั้นกว่า null check จริงเมื่อคุณมั่นใจ | ความเสี่ยงเหมือน as เป๊ะ เฉพาะเจาะจงกับ null/undefined — และ API response แทบไม่เคยแน่นอนอย่างที่ดูเลย |
@ts-ignore | ระงับ error บรรทัดถัดไปได้ทุกอย่าง ตลอดไป | ระงับ error อื่น ที่เพิ่งเกิดใหม่บนบรรทัดเดียวกันด้วย — คุณจะไม่มีทางรู้เลย |
@ts-expect-error | build fail ทันทีที่ error ที่ระงับไว้เลิกเป็นจริง | มีพิธีกรรมเยอะกว่านิดหน่อย ต้องรู้เจตนาที่แน่ชัด (คาดว่ามี error ตรงนี้) |
declare | ให้ใช้ value ที่ TypeScript มองไม่เห็น definition ได้ | ไม่มีการตรวจสอบเลยว่า value จริงตอน runtime ตรงกับที่ declare ไว้ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”asแปลง value เป็น target type เหมือน cast ในภาษาอื่น ไม่จริง มันไม่รัน code ใด ๆ —raw as unknown as numberปล่อยrawไว้เหมือนเดิมทุกประการ มันแค่เปลี่ยนสิ่งที่ compiler เชื่อเกี่ยวกับ expression นับจากจุดนั้น!เป็นทางลัดที่ปลอดภัยเมื่อคุณ “ค่อนข้างมั่นใจ” ว่า value ไม่ null มันมีพฤติกรรมไม่เช็ค runtime เหมือนasเป๊ะ — “ค่อนข้างมั่นใจ” ที่กลายเป็นผิดพังเหมือนการเข้าถึงnullแบบไม่เช็คทุกประการ ต่างกันแค่ warning ของ compiler หายไปแล้ว@ts-ignoreกับ@ts-expect-errorใช้แทนกันได้เพื่อระงับ error มันพังคนละทิศทางเมื่อ code เปลี่ยน:@ts-expect-errorต้องการให้ error ยังอยู่จริงและจะพังดัง ๆ ทันทีที่มันไม่อยู่แล้ว;@ts-ignoreไม่บอกอะไรเลยทั้งสองทาง ตลอดไปdeclareสร้าง value ที่มันอธิบาย ตรงข้ามเลย — มันยืนยัน type ให้ value ที่ต้องมีอยู่แล้วผ่านทางอื่น code ที่มีแต่declareemit ไม่ได้อะไรเลยและไม่สร้างอะไรเลย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”Code ที่ generate มามักหยิบ as, !, @ts-ignore และบางทีก็ declare ที่แต่งขึ้นมา ทันทีที่ type error สร้างความรำคาญ เพราะทั้งสี่อย่างทำให้เส้นแดงหายไปทันที ตอนเห็นอันไหนอันหนึ่งให้เช็ค:
- มี runtime check จริงที่ assertion นี้ใช้แทนไหม — null check, การ validate shape — ที่ code ที่ generate มาข้ามไปแทนที่จะเขียน?
@ts-ignoreอยู่บนบรรทัดที่ error message จริง (เห็นได้ถ้าลบ comment ออกชั่วคราว) อธิบาย bug จริงหรือเปล่า ไม่ใช่ false positive?declareอ้างถึง global หรือ module ที่มีอยู่จริงตอน runtime ใน project นี้ หรือถูกแต่งขึ้นมาเพื่อทำให้ import error หายไป?
🐛 หา bug ในโค้ดนี้
function getConfigValue(key: string): string {const config: Record<string, string | undefined> = { host: "localhost" };return config[key]!;}
const port = getConfigValue("port");console.log(port.toUpperCase());เฉลย
โค้ดนี้ compile ผ่านโดยไม่มี error เลยแล้วพังตอน runtime config[key] เป็น string | undefined จริง ๆ — compiler เตือนถูกต้องแล้วว่าการ lookup อาจพลาดได้ ! ไม่ได้เช็คอะไรเลย มันแค่ assert ว่า “key นี้มีอยู่แน่นอน” ซึ่งไม่จริงสำหรับ "port" ใน config object นี้ getConfigValue("port") คืน undefined จริง ๆ ตอน runtime ทั้งที่มี return annotation เป็น : string (annotation ไม่เคยถูกตรวจสอบกับความจริงเลย — ดูบท Type Inference เรื่องทำไมการ annotate public return type ถึงสำคัญ) และ port.toUpperCase() throw TypeError: Cannot read properties of undefined (reading 'toUpperCase') การแก้คือ check จริง — if (config[key] === undefined) throw new Error(...) หรือทำให้ return type ซื่อสัตย์เป็น string | undefined แล้วจัดการที่จุดเรียก — ไม่ใช่ ! ที่ assert check ทิ้งไป