Objects, Arrays & Tuples
object type และ modifier ของตัวเอง
หัวข้อที่มีชื่อว่า “object type และ modifier ของตัวเอง”object type อธิบาย shape ด้วยการ list property modifier สองตัวสื่อความหมายเยอะ:
interface User { readonly id: string; // can't be reassigned after creation name: string; // required email?: string; // optional — string | undefined}readonlyบล็อกการ reassign property เป็น compile-time เท่านั้น (runtime ไม่ได้กันการ mutate) แต่สื่อและ enforce เจตนา?(optional) หมายถึง property อาจไม่มี สำคัญคือ optional property มี typeT | undefinedคุณจึงต้อง handle caseundefined:
// @expect-error TS18048interface User { email?: string;}
function greet(u: User) { u.email.toLowerCase(); // ❌ 'u.email' is possibly 'undefined'. u.email?.toLowerCase(); // ✅ optional chaining handles the absence}interface กับ type แทบใช้แทนกันได้สำหรับ object shape; interface เปิดต่อได้ (declaration merging) และ idiomatic สำหรับ public object contract ส่วน type ยังเขียน union, tuple และ mapped type ได้ ใช้ type เมื่อต้องการพวกนั้น สำหรับ object ธรรมดาใช้อันไหนก็ได้
Excess property check: เฉพาะ literal แบบ fresh เท่านั้น
หัวข้อที่มีชื่อว่า “Excess property check: เฉพาะ literal แบบ fresh เท่านั้น”การ assign object literal ตรง ๆ ไปที่ type ที่ต้องการได้ check เพิ่มที่การ assign ปกติไม่มี: ทุก property ใน literal ต้องรู้จักโดย target type
// @expect-error TS2561interface Options { width: number; height?: number }function create(opts: Options) {}
create({ width: 100, heigth: 200 });// ❌ Object literal may only specify known properties, but 'heigth' does not exist// in type 'Options'. Did you mean to write 'height'?check นี้มีไว้จับ typo บน value ที่ compiler ไม่เคยเห็นถูก assign ที่ไหนมาก่อนเท่านั้น — มันไม่ทำงานอีกต่อไปเมื่อ object ผ่านตัวแปรมาก่อน เพราะตอนนั้น object อาจมี property มากกว่าที่ target type ต้องการได้อย่างถูกต้อง (structural typing คือเรื่องของการมีอย่างน้อยตาม shape ที่ต้องการ):
interface Options { width: number; height?: number }function create(opts: Options) {}
const o = { width: 100, heigth: 200 }; // same typocreate(o); // ✅ no error -- o is not a fresh literal anymoreนี่คือช่องว่างจริง ไม่ใช่บั๊ก: o อาจถูก share กับโค้ดอื่นที่อ่าน heigth เพื่อเหตุผลอื่นอย่างถูกต้อง ถ้าอยากได้ความเข้มงวดกลับมา annotate ตัวแปรเองตรง ๆ (const o: Options = { ... }) ซึ่งจะ trigger fresh-literal check ใหม่ตอนที่ประกาศ
exactOptionalPropertyTypes: ? เทียบ | undefined
หัวข้อที่มีชื่อว่า “exactOptionalPropertyTypes: ? เทียบ | undefined”โดย default optional property width?: number ยอมรับการ assign undefined แบบชัดเจนเหมือนกับ property ที่ไม่มี exactOptionalPropertyTypes (ปิดโดย default แม้อยู่ใต้ strict) ทำให้ความต่างนี้เป็นจริง: ? แปลว่า “อาจไม่มี” ไม่ใช่ “อาจเป็น undefined”
interface Options { width?: number }const a: Options = { width: undefined }; // compiles todayรันด้วย flag เปิดอยู่ (npx tsc --noEmit --strict --exactOptionalPropertyTypes …) ไฟล์เดียวกันจะ fail:
error TS2375: Type '{ width: undefined; }' is not assignable to type 'Options' with'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of thetarget's properties. Types of property 'width' are incompatible. Type 'undefined' is not assignable to type 'number'.เปิดใช้เมื่อโค้ดของคุณแยกความหมาย “caller ไม่ได้ส่งค่านี้มา” กับ “caller ส่ง undefined มาตรง ๆ” (พบบ่อยกับ partial update และ form state) — ถ้าไม่เปิด ทั้งสองกรณีจะยุบเหลือ runtime value เดียวกัน และ type system แยกไม่ออก
object เทียบ Object เทียบ {}: การอนุญาตสามแบบที่ต่างกัน
หัวข้อที่มีชื่อว่า “object เทียบ Object เทียบ {}: การอนุญาตสามแบบที่ต่างกัน”ทั้งสามตัวหน้าตาคล้ายกันมากแต่ทำงานต่างกันมาก ภายใต้ strict ทั้งสามปฏิเสธ null และ undefined — แต่ไม่เห็นตรงกันเรื่อง primitive:
// @expect-error TS2322let a: object;let b: Object;let c: {};
a = { x: 1 }; b = { x: 1 }; c = { x: 1 }; // ✅ all three
a = 5; // ❌ object explicitly excludes primitiveslet b: Object;let c: {};b = 5; // ✅ Object accepts primitives -- they structurally have toString/valueOf/etc.c = 5; // ✅ {} means "any value except null/undefined" -- almost as permissive as Objectobject(ตัวพิมพ์เล็ก สร้างขึ้นมาเพื่อสิ่งนี้โดยเฉพาะ) หมายถึง “อะไรก็ได้ที่ไม่ใช่ primitive” — type ที่ไม่ใช่ primitive จริง ๆObjectคือ interface ที่มีtoString,valueOf,hasOwnPropertyและเพื่อน ๆ — primitive มี method พวกนี้ด้วยเชิง structural (ผ่าน boxing) เลย assign ได้{}หมายถึง “value ที่ไม่มี property จำเป็น” — ซึ่งเกือบทุก value ที่ไม่ nullish ตอบโจทย์ได้ รวมถึง primitive
ในทางปฏิบัติ: หยิบ object มาใช้เมื่อตั้งใจจะหมายถึง “ไม่ใช่ primitive” โดยเฉพาะ หยิบ interface จริงหรือ unknown มาใช้กับที่เหลือ และหลีกเลี่ยง Object/{} เป็น parameter type — มันรับได้มากกว่าที่หน้าตามันบอก
ReadonlyArray, array hole และ noUncheckedIndexedAccess
หัวข้อที่มีชื่อว่า “ReadonlyArray, array hole และ noUncheckedIndexedAccess”readonly number[] (หรือ ReadonlyArray<number>) ห้าม method ที่ mutate อย่าง push และการ assign ที่ index:
// @expect-error TS2339function paint(colors: readonly string[]) { colors.push("red"); // ❌ push does not exist on readonly string[]}การเลือกใช้ readonly array parameter เป็นนิสัยราคาถูกที่ได้ค่ามาก — กันบั๊ก mutate โดยไม่ตั้งใจทั้งชุด แต่ readonly ไม่พูดอะไรเกี่ยวกับอันตรายอีกแบบ: การอ่านเลยขอบเขต โดย default index ที่เกิน bounds ถูก type ราวกับว่าสำเร็จเสมอ:
const arr: number[] = [1, 2, 3];const x = arr[10]; // out of bounds -- but typed as number, not number | undefinedconsole.log(x.toFixed(2)); // compiles -- crashes at runtimeเปิด noUncheckedIndexedAccess (ไม่ได้อยู่ใน strict เปิดแยกเองเท่านั้น) ทำให้ทุก index access ซื่อสัตย์ขึ้นด้วยการเพิ่ม | undefined:
error TS18048: 'x' is possibly 'undefined'.นี่คือ flag ที่คุ้มค่าที่สุดตัวหนึ่งที่ไม่ได้อยู่ใน strict — index signature (จากก่อนหน้า) และการเข้าถึง array ธรรมดาได้ความซื่อสัตย์เดียวกันเรื่องการไม่มีค่า แลกกับ narrowing check เพิ่มตรงจุดที่ index ด้วยตัวแปร
Optional และ variadic tuple element
หัวข้อที่มีชื่อว่า “Optional และ variadic tuple element”tuple รองรับ element แบบ optional (? ต้องอยู่หลัง required element) และ rest element แบบ variadic ตรงไหนก็ได้ ไม่ใช่แค่ท้ายสุด:
type Point = [x: number, y: number, z?: number];const p1: Point = [1, 2]; // ✅ z omittedconst p2: Point = [1, 2, 3]; // ✅ z provided
type Padded = [start: number, ...middle: string[], end: boolean]; // rest in the middleconst pd: Padded = [1, "a", "b", true];
function measure(...args: [string, ...number[]]): void {}measure("cm", 1, 2, 3); // the rest parameter itself is typed as a variadic tupletype ของ optional tuple element รวม undefined เหมือน optional object property; variadic rest element วางตรงไหนใน tuple ก็ได้ ตราบใดที่ compiler ยังบอกได้ว่ามันจบตรงไหน (ปกติเพราะมี suffix ความยาวคงที่ตามหลังมัน)
index signature: object เป็น map
หัวข้อที่มีชื่อว่า “index signature: object เป็น map”เมื่อยังไม่รู้ key ล่วงหน้า index signature type ทุก key แบบ uniform:
interface Scores { [player: string]: number;}
const s: Scores = { ada: 10, alan: 12 };s.grace = 9; // ✅ any string key, number valueราคาที่ต้องจ่ายคือความซื่อสัตย์เรื่องการไม่มีค่า: s.someone มี type number แต่จริง ๆ เป็น undefined ตอน runtime ด้วย option noUncheckedIndexedAccess TypeScript จะทำให้เรื่องนี้เป็นจริง โดย type ทุก index access เป็น number | undefined — เป็น setting ที่แนะนำอย่างยิ่ง
array เทียบ tuple
หัวข้อที่มีชื่อว่า “array เทียบ tuple”array คือ list ที่ homogeneous และยาวเท่าไรก็ได้: number[] คือ “number กี่ตัวก็ได้” tuple คือ list ความยาวคงที่ที่แต่ละตำแหน่งมี type ของตัวเอง:
let list: number[] = [1, 2, 3, 4]; // any length, all numberlet point: [number, number] = [10, 20]; // exactly two numberslet pair: [string, number] = ["age", 36]; // position 0 string, position 1 numbertuple ขับเคลื่อน pattern อย่าง return ของ useState ใน React ([value, setter]) และพิกัดแบบมีชื่อกลาย ๆ tuple ยังรองรับ label (เป็น documentation ไม่มีผล runtime) และ rest element:
type HttpResult = [status: number, body: string];type Args = [first: string, ...rest: number[]];flowchart TB arr["number[] any length · all same type"] --> arrEx["[1, 2, 3, 4, ...]"] tup["[string, number] fixed length · per-position type"] --> tupEx["['age', 36]"]
readonly array และ tuple
หัวข้อที่มีชื่อว่า “readonly array และ tuple”readonly number[] (หรือ ReadonlyArray<number>) ห้าม method ที่ mutate อย่าง push และการ assign ที่ index — เหมาะกับ parameter ที่คุณสัญญาว่าจะไม่แก้ และจำจากบทที่แล้ว: as const บน array literal ให้ readonly tuple ของ literal พอดี:
const rgb = [255, 128, 0] as const;// type: readonly [255, 128, 0]
function paint(color: readonly number[]) { /* can't mutate color */ }การเลือกใช้ readonly array parameter เป็นนิสัยราคาถูกที่ได้ค่ามาก: กันบั๊ก mutate โดยไม่ตั้งใจทั้งชุด และสื่อเรื่อง ownership
enum ประกาศชุด named constant ต่างจากแทบทุกอย่างในคอร์สนี้ตรงที่มันไม่ถูก erase — มันสร้าง runtime code จริง นั่นคือเหตุผลที่มันต้องมี section ของตัวเอง และเหตุผลที่มันชนกับทุกเครื่องมือ “erasable syntax” ในบทหลัง ๆ
Numeric enum มี reverse mapping
หัวข้อที่มีชื่อว่า “Numeric enum มี reverse mapping”enum ธรรมดานับ member จาก 0 เว้นแต่คุณจะระบุเอง และ — เฉพาะ TypeScript เท่านั้นในบรรดา feature ทั้งหมด — compiler ยัง emit reverse mapping จาก value กลับไปหาชื่อด้วย:
enum Color { Red, Green, Blue }console.log(Color.Green); // 1 -- forward: name to valueconsole.log(Color[1]); // "Green" -- reverse: value to nameenum ไม่ถูก erase เลย — ต่างจากทุก fence ในคอร์สนี้ — อันนี้รันด้วย node --experimental-strip-types ไม่ได้ (ดูข้างล่าง) ที่นี่มันแค่ type-check เท่านั้น compile จริง (tsc --module commonjs) แล้วรัน JavaScript ที่ emit ออกมาด้วย node ธรรมดา จะพิมพ์ 1 แล้ว Green ตรงกับ comment ข้างบน reverse mapping นั้นเป็น JavaScript ที่ generate ออกมาจริง ไม่ใช่ trick ระดับ type:
var Color;(function (Color) { Color[Color["Red"] = 0] = "Red"; Color[Color["Green"] = 1] = "Green"; Color[Color["Blue"] = 2] = "Blue";})(Color || (Color = {}));แต่ละบรรทัดทำ assign สองอย่าง: Color["Red"] = 0 (forward) และ Color[0] = "Red" (reverse) — นั่นคือกลไกทั้งหมด
String enum ไม่มี reverse mapping
หัวข้อที่มีชื่อว่า “String enum ไม่มี reverse mapping”string enum assign แค่ทาง forward เท่านั้น การ index ด้วย value จึงใช้ไม่ได้ — และ object ที่ emit ออกมาก็พิสูจน์:
// @expect-error TS2551enum Direction { Up = "UP", Down = "DOWN" }console.log(Direction.Up);console.log(Direction["UP"]); // ❌ typo -- the member is named "Up", not "UP"compile version ที่แก้แล้วแล้วรัน JavaScript ที่ emit ออกมา (tsc --module commonjs แล้วรันด้วย node ธรรมดา เพราะ string enum ก็ non-erasable และรันด้วย --experimental-strip-types ไม่ได้เหมือนกัน — ดูข้างล่าง) แสดง runtime object ให้เห็นตรง ๆ:
console.log(Direction) → { Up: 'UP', Down: 'DOWN' }มีแค่ forward key สองตัว — ไม่มี reverse entry 0/1 เพราะไม่มีอะไรเป็นตัวเลขให้ map กลับ
”type safety” ของ numeric enum จับได้แค่ literal ไม่ใช่ number จริง
หัวข้อที่มีชื่อว่า “”type safety” ของ numeric enum จับได้แค่ literal ไม่ใช่ number จริง”numeric enum ปฏิเสธ literal ที่นอกช่วง ซึ่งให้ความรู้สึกเหมือน type safety จริง:
// @expect-error TS2345enum Color { Red, Green, Blue }function paint(c: Color) {}paint(999); // out of range -- no member has this valueแต่ check นั้นทำงานแค่กับ literal ที่ compiler มองเห็นและจับคู่กับ value ของ member ได้เท่านั้น — number ที่มาจาก runtime แบบสุ่ม (response จาก API, output ของ parseInt, อะไรก็ตามที่ไม่ใช่ literal) ผ่านไปได้โดยไม่ถูกเช็คเลย รวมถึงเรื่อง range ด้วย:
enum Color { Red, Green, Blue }function paint(c: Color) {}
paint(Color.Green); // ✅ intended usagepaint(2); // ✅ 2 happens to equal Color.Blue's value, so the literal check allows it
declare const n: number; // any runtime number -- not a literal the checker can range-checkpaint(n); // ✅ still compiles -- no bounds check against a non-literal numberนี่คือ unsoundness จริงของ numeric enum: ในระดับ type Color ดูเหมือนเซตปิดสามค่า แต่ตอน runtime มันก็แค่ number และ number อะไรก็ assign ได้
const enum: inline ค่าเข้าไปเลย และทำไมมันรอดจาก bundling จริงไม่ได้
หัวข้อที่มีชื่อว่า “const enum: inline ค่าเข้าไปเลย และทำไมมันรอดจาก bundling จริงไม่ได้”const enum ไม่ emit object เลย — ทุกที่ที่อ้างถึงถูกแทนที่ด้วย literal value ตรง ๆ ที่จุดที่ใช้:
const enum Direction { Up, Down }console.log(Direction.Up);"use strict";console.log(0 /* Direction.Up */);การ inline นั้นเองที่ทำให้มันพังใน multi-file build จริง: tsc ทำได้เพราะมัน type-check ทั้ง program พร้อมกัน มองเห็น value ของทุก const enum ได้ single-file transpiler (esbuild, SWC, Babel หรือ tsc --isolatedModules ที่เจอ const enum แบบ ambient จาก .d.ts) ประมวลผลไฟล์เดียวแบบแยกขาด ไม่มีทางไป lookup value ได้:
// @skip-verify illustrative -- the import target is a second file (a real .d.ts) in a genuine multi-file build; verified separately in scratch with two real files, see the quoted output belowimport { Direction } from "./somewhere"; // declared there as `export declare const enum Direction { ... }` in a .d.tsconsole.log(Direction.Up);ตรวจแล้วจริงใน scratch ด้วยไฟล์จริงสองไฟล์ — direction.d.ts ที่ประกาศ export declare const enum Direction { Up, Down } กับ main.ts ที่ import จากมัน — npx tsc --ignoreConfig --noEmit --target es2025 --module esnext --isolatedModules main.ts ให้:
main.ts(2,13): error TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled.isolatedModules เป็นสิ่งที่ toolchain แบบ bundler สมัยใหม่แทบทุกตัวต้องการโดยปริยาย เลย const enum ที่ import จาก dependency ที่ compile แล้ว (แทนที่จะเป็นจาก source ใน program เดียวกัน) คือการพังจริงที่พบบ่อย ไม่ใช่แค่สมมติฐาน
--erasableSyntaxOnly ของ TypeScript 7 ปฏิเสธ enum ตรง ๆ
หัวข้อที่มีชื่อว่า “--erasableSyntaxOnly ของ TypeScript 7 ปฏิเสธ enum ตรง ๆ”เพราะ enum สร้าง code จริง มันเลยสอบตกเทสต์ “erasable syntax” แบบเดียวกับที่บท erasure ใช้กับ parameter property รันด้วย flag เปิด (harness แบบ flag ตายตัวของคอร์สนี้ toggle มันไม่ได้ นี่คือคำสั่งและ output จริงจากเครื่องนี้):
$ npx tsc --ignoreConfig --noEmit --erasableSyntaxOnly --target es2025 enum.tsenum.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.ทั้ง enum ธรรมดาและ const enum โดน TS1294 เหมือนกันเป๊ะ — const เปลี่ยนแค่กลยุทธ์ emit ไม่ใช่ว่า syntax erasable หรือเปล่า
การ strip type ของ Node ปฏิเสธมันด้วยกฎเดียวกัน
หัวข้อที่มีชื่อว่า “การ strip type ของ Node ปฏิเสธมันด้วยกฎเดียวกัน”node --experimental-strip-types บังคับกฎเดียวกันในระดับ runtime โดยไม่ขึ้นกับ flag ของ TypeScript เลย:
$ node --experimental-strip-types enum.tsSyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only modeตัวแทน: object แบบ as const บวก keyof typeof
หัวข้อที่มีชื่อว่า “ตัวแทน: object แบบ as const บวก keyof typeof”ตัวแทนที่ idiomatic และ erasable คือ object literal ธรรมดา แช่แข็งด้วย as const แล้ว derive union type จากค่าของมันเอง:
// @runconst Color = { Red: 0, Green: 1, Blue: 2,} as const;
type Color = typeof Color[keyof typeof Color]; // 0 | 1 | 2
function paint(c: Color) {}paint(Color.Green); // ✅console.log(Color.Green);// 1อันนี้รอดจาก erasure ได้เต็ม ๆ (มันแค่ const กับ type alias erasable ทั้งคู่) ใช้ได้กับ node --experimental-strip-types ตรง ๆ ไม่ต้องแก้อะไร และให้ autocomplete บน Color.___ เหมือน enum จริง สิ่งที่มันไม่ให้คือ reverse mapping — ถ้าต้องการ lookup แบบ value→name ต้องสร้างเอง (Object.entries(Color)) เพราะ object แบบ as const ไม่สร้างให้อัตโนมัติ
as const object replacing an enum
enum Color { Red, Green, Blue }console.log(Color.Green); // 1console.log(Color[1]); // "Green" -- numeric reverse mapping
const ColorConst = { Red: 0, Green: 1, Blue: 2,} as const;type ColorConst = typeof ColorConst[keyof typeof ColorConst];
function paint(c: ColorConst) {}paint(ColorConst.Green); // erasable replacement -- same autocomplete, no runtime object needed built-in ใหม่ ๆ ที่ lib ใส่ type ให้แล้ว (es2024 / es2025)
หัวข้อที่มีชื่อว่า “built-in ใหม่ ๆ ที่ lib ใส่ type ให้แล้ว (es2024 / es2025)”เมื่อ target และ lib เป็น es2025 (ค่าเริ่มต้นของ TypeScript 7) โค้ดจัดการ collection หลายอย่างที่เคยต้องพึ่ง helper library ก็มี type มาให้ทันที:
-
Set algebra (5.5):
a.union(b),intersection,difference,symmetricDifference,isSubsetOf,isSupersetOf,isDisjointFrom— ทุกตัว returnSetที่ type ถูกต้อง -
Iterator helper (5.6): generator และ built-in iterator มี
.map,.filter,.take,.drop,.flatMap,.reduce,.toArrayและIterator.from(...)type ของมันคือIteratorObject<T>(และAsyncIteratorObject<T>) แยกจาก interfaceIteratorที่คุณ implement เอง:function* naturals() { let i = 1; while (true) yield i++; }const evens = naturals().map((n) => n * 2).take(3).toArray(); // number[]const userById = new Map<number, { email: string }>([]);const byEmail = new Map(userById.entries().map(([id, u]) => [u.email, id])); -
grouping (es2024):
Object.groupBy(items, (x) => x.kind)returnPartial<Record<K, T[]>>;Map.groupByreturnMap -
upsert (6.0):
map.getOrInsert(key, fallback)และmap.getOrInsertComputed(key, () => …) -
อยู่ใน
es2025ด้วย:RegExp.escape,Promise.try,Promise.withResolversและ type ของ Temporal ในตัว (Temporal.Now.instant()) ไม่ต้องลง@typesเพิ่มแล้ว
typed array เป็น generic แล้ว (5.7)
หัวข้อที่มีชื่อว่า “typed array เป็น generic แล้ว (5.7)”Uint8Array และพวกพ้องกลายเป็น generic เหนือ buffer ของมัน — Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike> — และใน 5.7 ArrayBuffer เลิกเป็น supertype ของ typed array ผลในทางปฏิบัติ: การส่ง Uint8Array เข้า parameter ที่ type เป็น ArrayBuffer ไม่ทำงานแบบเดิมอีกแล้ว แก้ด้วยการส่ง .buffer เมื่อคุณเป็นเจ้าของ API:
// @expect-error TS2345const data = new Uint8Array([1, 2, 3]); // Uint8Array<ArrayBuffer>function send(b: ArrayBuffer) {}send(data.buffer); // ✅ ArrayBuffer
// A plain Uint8Array (no buffer type argument) is Uint8Array<ArrayBufferLike> —// its .buffer can be a SharedArrayBuffer, so it still fails:declare const generic: Uint8Array;send(generic.buffer); // ❌ ArrayBufferLike is not assignable to ArrayBufferusing: cleanup resource ตาม scope (5.2)
หัวข้อที่มีชื่อว่า “using: cleanup resource ตาม scope (5.2)”Explicit Resource Management — using และ await using — มาใน TypeScript 5.2 และตอนนี้ Node.js 24+ กับ browser สมัยใหม่รันได้ native ค่าที่มี [Symbol.dispose] (หรือ [Symbol.asyncDispose]) จะถูก cleanup เมื่อออกจาก block ในลำดับย้อนกลับ แม้จะ throw:
import * as fs from "node:fs";
function openFile(path: string) { const fd = fs.openSync(path, "r"); return { fd, [Symbol.dispose]() { fs.closeSync(fd); } };}
function read() { using file = openFile("data.txt"); // closed automatically at block end return fs.readFileSync(file.fd);}Disposable และ AsyncDisposable คือ interface ที่ต้อง implement ส่วน DisposableStack รวมหลายตัวไว้ด้วยกัน model resource แบบนี้แทนบันได try/finally
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”flowchart TD
v["A value flows toward a typed slot"] --> fresh{"Is it a fresh object literal,
written right at the call site?"}
fresh -->|"yes"| epc["Excess property check runs
(catches typos)"]
fresh -->|"no -- via a variable"| indirect["Only structural compatibility checked
(extra properties allowed)"]
v --> idx{"Indexing into an array/object
by a non-constant key?"}
idx -->|"noUncheckedIndexedAccess off"| trust["Trusted -- T, no undefined"]
idx -->|"on"| honest["Honest -- T | undefined"]
v --> lit{"Is it a number literal into an enum-typed slot?"}
lit -->|"yes, matches a member"| passes["Checked against real member values"]
lit -->|"no -- a plain number variable"| unchecked["Zero range checking -- any number passes"] แทบทุกเรื่องในบทนี้เป็นการเล่นกับคำถามเดียวกัน: ตรงจุดนี้ compiler ตรวจอะไรจริง ๆ บ้าง? excess property check ตรวจเยอะมาก แต่แค่ตอน literal เพิ่งเกิด noUncheckedIndexedAccess ตัดสินว่าการ “อ่านเลยขอบเขต” เป็นเงื่อนไขที่ถูกตรวจหรือเปล่า parameter type ของ numeric enum ดูรัดกุมแต่ตรวจแค่ literal ที่ pattern-match กับ member ที่รู้จักได้เท่านั้น — ตัวแปร number ธรรมดาเดินผ่านไปได้เฉย ๆ ไม่มีอันไหนเป็นบั๊กของ compiler เลย มันคือขอบเขตของสิ่งที่ static analysis สัญญาได้ และการรู้ว่าขอบเขตแต่ละอันอยู่ตรงไหนแม่นยำ คือสิ่งที่แยก “type system ปกป้องฉัน” ออกจาก “type system ปกป้องฉันตรงนี้ แต่ไม่ตรงนั้น”
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| Feature | ได้อะไร | เสียอะไร |
|---|---|---|
| Excess property check | จับ typo บน object literal ได้ทันที | ใช้ได้แค่กับ literal แบบ fresh — อ้อมผ่านตัวแปรตัวเดียวก็ปิดไปแล้ว |
exactOptionalPropertyTypes | แยก “ไม่มี” ออกจาก “ส่ง undefined มาตรง ๆ” | ปิดโดย default แม้ใต้ strict ต้องตรวจโค้ดเดิมที่พึ่งพฤติกรรมหลวม ๆ |
object/{} เป็น parameter type | หลวมมาก ตอบโจทย์ง่าย | รับได้มากกว่าที่หน้าตาบอก รวม primitive สำหรับ {} และ Object |
noUncheckedIndexedAccess | ทำให้ index read ทุกตัวซื่อสัตย์เรื่องการไม่มีค่า | เพิ่ม ` |
enum จริง | syntax คุ้นเคย มี reverse mapping ให้ฟรี (numeric) | non-erasable — ถูกปฏิเสธโดย erasableSyntaxOnly และ type-stripping ของ Node; check range อ่อนกับ number ที่ไม่ใช่ literal |
object แบบ as const แทน enum | erasable เต็มที่ ใช้ได้ทุกที่ autocomplete เหมือนกัน | ไม่มี reverse mapping อัตโนมัติ ต้องสร้างเองถ้าต้องการ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- numeric enum คือเซตปิดของ value ที่ถูกเช็คแล้ว แค่ literal ที่เป็น enum member เท่านั้นที่ถูกเช็คกับมัน
numberตัวไหนก็ได้ — จากparseInt, API body,Math.random()— assign เข้า parameter ที่ type เป็น enum ได้โดยไม่มีการเช็ค range เลย const enumแค่เป็นenumเวอร์ชันเร็วกว่า มันเปลี่ยนกลยุทธ์ emit (inline ค่าแทนที่จะสร้าง object) ซึ่งนั่นแหละคือเหตุผลที่มันพังใต้isolatedModulesเมื่อ value อยู่ในไฟล์ที่ compile แยกกัน — เป็นอันตรายจริงของ bundler ไม่ใช่แค่เชิงอรรถเรื่อง performanceObjectกับ{}เข้มงวดกว่าobjectเพราะดู “เจาะจง” กว่า ตรงกันข้ามเลย:objectคือตัวที่เข้มงวด (ไม่รับ primitive);Objectกับ{}รับได้แทบทุกอย่างที่ไม่ nullish รวม primitive- การอ่าน array เกิน bounds ถูกจับโดย
strictอยู่แล้ว ไม่จริง —strictไม่รวมnoUncheckedIndexedAccessถ้าไม่เปิด flag แยกเองarr[999]จะถูก type ราวกับว่าสำเร็จเสมอ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ object, array, tuple หรือ enum ที่ generate มา ให้เช็คสิ่งเหล่านี้:
- function parameter ที่ type เป็น numeric
enumที่รับ value จาก boundary ที่ไม่มี type (req.body,JSON.parse, แถวจาก database) โดยไม่ validate membership ก่อน — type system จับ number นอกช่วงตรงนั้นไม่ได้ switchที่ครอบทุก enum member โดยไม่มีdefaultถูกมองว่าเป็นหลักฐานว่า function return เสมอ — มัน exhaustive สำหรับ value ที่ compiler พิสูจน์ได้ว่าเป็น member จริง เท่านั้น ไม่ใช่สำหรับอะไรก็ตามที่ caller ส่งผ่าน parameter ที่ widen แล้ว- object literal ที่ผ่าน helper (
Object.assign, spread, factory function) ก่อนถึง parameter ที่ type เข้มงวด — excess property check ที่ควรจะจับ typo ตอน literal ไม่มีโอกาสได้ทำงานเลย - index access บน array หรือ
Recordด้วย computed key โดยไม่มี narrowing ให้เห็นทีหลัง และไม่มีnoUncheckedIndexedAccessในtsconfig.jsonของ project — น่าจะเป็น “possibly undefined” แบบเงียบ ๆ
🐛 หา bug ในโค้ดนี้
enum Status { Pending, Active, Closed }
function label(s: Status): string {switch (s) { case Status.Pending: return "pending"; case Status.Active: return "active"; case Status.Closed: return "closed";}}
function fromApi(rawStatus: number): string {return label(rawStatus); // rawStatus came from an API response body -- any number}
console.log(fromApi(999).toUpperCase());เฉลย
โค้ดนี้ compile ผ่านโดยไม่มี error เลย ด้วยสองเหตุผลที่แยกจากกันแล้วซ้อนทับกัน อย่างแรก switch ของ label ครอบทุก member ของ Status เลย TypeScript ยอมรับว่า exhaustive โดยไม่ต้องมี default — แต่ exhaustiveness นั้นเป็นแค่สัญญาเกี่ยวกับ value ของ Status จริง ๆ อย่างที่สอง fromApi รับ number ธรรมดาแล้วส่งตรงเข้า label(s: Status) และ parameter type ของ numeric enum ไม่มีการเช็ค range กับ number ที่ไม่ใช่ literal — rawStatus เลยผ่านไปโดยไม่ถูกแตะเลย ตอน runtime label(999) ไม่ตรงกับ case ไหนเลย ตกออกจากท้าย switch แล้ว return undefined จาก function ที่ประกาศว่า return string การ crash จริงคือ TypeError: Cannot read properties of undefined (reading 'toUpperCase') ทางแก้คือ validate ตรง boundary — ไม่ว่าจะเป็น default: throw new Error(...) ที่ชัดเจนใน label หรือเช็ค rawStatus in Status (หรือเทียบกับ Object.values(Status)) ก่อนจะเรียก label เลย