ข้ามไปยังเนื้อหา

Why 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 TS2561
function 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 ก่อนที่อะไรจะรัน การเปลี่ยนแปลงนั้น — จาก “พังทีหลัง ที่ไหนสักแห่ง” ไปเป็น “พังตอนนี้ ตรงนี้” — คือคุณค่าทั้งหมดของตัวเอง

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 อยู่แล้ว)"]
type ถูกเช็ค แล้วถูกลบทิ้ง
  • Compile time — type checker อ่าน annotation และ type ที่ infer ได้ของคุณ แล้วรายงาน error ความปลอดภัยทั้งหมดเกิดขึ้นตรงนี้
  • Runtime — JavaScript ที่ emit ออกมารัน type หายไปหมดแล้ว เพราะถูกลบทิ้งตอน compile

นี่คือเหตุผลที่คุณทำแบบนี้ไม่ได้:

// @expect-error TS1005
function 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

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 (กรกฎาคม 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.0 tsconfig.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”) ให้รู้ว่าอะไรเพิ่งมา

ทุก 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

// @run
function 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 check
console.log(user.name.toUpperCase());
Open in TS Playground

find("1") คืน undefined จริง ๆ ตรงนี้ — ! ไม่ได้เปลี่ยนความจริงข้อนั้น มันแค่บอก compiler ให้เลิกกังวลเรื่องนี้ โปรแกรม compile ผ่านสะอาดแล้ว throw TypeError: Cannot read properties of undefined (reading 'name') ทันทีที่รัน ตรงกับ crash ที่ strictNullChecks มีไว้ป้องกันพอดี เหมือน as: assertion คือคำกล่าวอ้างเกี่ยวกับ value ไม่ใช่การแปลง value นั้น

ทั้งสอง comment ระงับ error บรรทัดถัดไปเหมือนกัน — แต่พังคนละทิศทางเมื่อ code รอบ ๆ เปลี่ยนไปทีหลัง ซึ่งเป็นคุณสมบัติเดียวที่สำคัญใน codebase จริง

// @ts-ignore
const total: number = "not a number"; // a real, live TS2322 — now silenced forever
console.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-error
const 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

บางครั้ง 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 ของมันเท่านั้น

ทุกอย่างในบทนี้คือไอเดียเดียวที่ถูกใช้สองครั้ง: 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"]
สองวิธีส่งข้อเท็จจริงที่ไม่ผ่านการตรวจให้ compiler
เครื่องมือข้อดีต้นทุน
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-errorbuild 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 ที่มีแต่ declare emit ไม่ได้อะไรเลยและไม่สร้างอะไรเลย

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 ทิ้งไป

เกิดอะไรขึ้นกับ type ของ TypeScript เมื่อ code ถูก compile เป็น JavaScript?
คุณค่าหลักของ TypeScript คืออะไร?
ทำไมคุณถึงเช็ค type ของ TypeScript ตรง ๆ ตอน runtime ไม่ได้?
เกิดอะไรขึ้นตอนรัน?
const raw: string = "42";
const n = raw as unknown as number;
console.log(n.toFixed(2));
ความต่างสำคัญระหว่าง @ts-ignore กับ @ts-expect-error คืออะไร?
โค้ดนี้ compile ผ่านไหม และเกิดอะไรขึ้นตอนรัน?
function getConfigValue(key: string): string {
  const config: Record<string, string | undefined> = { host: "localhost" };
  return config[key]!;
}
const port = getConfigValue("port");
console.log(port.toUpperCase());
declare const BUILD_VERSION: string; compile ออกมาเป็นอะไรใน JavaScript ที่ emit?