🧰 ของที่ใช้ตรวจในบทนี้ (รันวันที่ 27 สิงหาคม 2026)
tsc Version 7.0.2 # จาก tsc -v
node v24.14.0 # จาก node --version
ทุกกล่องที่เขียนว่า "ได้ผลว่า…" ในบทนี้มาจากการรันจริงสองคำสั่งนี้:
tsc --noEmit --strict ไฟล์.ts — ตรวจ type
tsc --target es2022 --module nodenext --moduleResolution nodenext ไฟล์.ts && node ไฟล์.js — ดูค่าจริง
ข้อความ error ทุกอันก็อปมาจาก tsc ตัวจริง ทั้งเลข TSxxxx และตัวหนังสือ
ไม่ได้พิมพ์เอง — คุณ copy โค้ดไปรันซ้ำได้เลย
1 · เริ่มจาก for loop ที่คุณคุ้นอยู่แล้ว
สมมติมีรายการออเดอร์ แล้วอยากรู้ว่ายอดรวมของออเดอร์ที่จ่ายเงินแล้วเป็นเท่าไหร่
ถ้าเขียนแบบที่คุณเขียนใน Go มา 9 วัน โครงจะเหมือน Go เป๊ะ ต่างแค่ชื่อ type
ข้อมูลตั้งต้นที่ใช้ทั้งบทนี้ — ก็อปไปวางไว้บนสุดของไฟล์แล้วรันตามได้เลย:
interface Order {
id: number;
status: string;
amount: number;
}
const orders: Order[] = [
{ id: 1, status: "paid", amount: 250 },
{ id: 2, status: "pending", amount: 120 },
{ id: 3, status: "paid", amount: 150 },
{ id: 4, status: "cancelled", amount: 80 },
];
แล้วโค้ดที่คำนวณยอดรวมของออเดอร์ที่จ่ายแล้ว:
let totalLoop = 0;
for (const o of orders) {
if (o.status === "paid") {
totalLoop = totalLoop + o.amount;
}
}
อ่านออกทันทีใช่ไหม — แต่สังเกตว่ามันทำ 3 เรื่องปนกันอยู่ในก้อนเดียว:
คัดว่าเอาอันไหน · หยิบเอาเฉพาะตัวเลข · แล้วบวกสะสม
ถ้าใครมาอ่านโค้ดนี้ทีหลัง เขาต้องอ่านครบทั้ง 5 บรรทัดก่อนถึงจะรู้ว่ามันคำนวณอะไร
สามตัวในบทนี้คือการแยก 3 เรื่องนั้นออกจากกัน ให้แต่ละเรื่องมีชื่อของตัวเอง:
| อยากทำอะไร | ใช้ตัวไหน | ได้อะไรกลับมา |
|---|---|---|
| แปลงของทุกชิ้นเป็นของใหม่ | map | array ใหม่ ยาวเท่าเดิม |
| คัดเอาเฉพาะชิ้นที่เข้าเงื่อนไข | filter | array ใหม่ สั้นลงหรือเท่าเดิม |
| ยุบทั้งกองให้เหลือค่าเดียว | reduce | ค่าเดียว (จะเป็นอะไรก็ได้) |
2 · map — แปลงทีละชิ้น ได้กองใหม่ที่ยาวเท่าเดิม
map แปลว่า "เดินไปทีละชิ้น ทำอะไรกับมันสักอย่าง แล้วเอาผลไปเรียงในกองใหม่"
กองเก่ามีกี่ชิ้น กองใหม่ก็มีเท่านั้นชิ้น เสมอ — ไม่มีทางหายและไม่มีทางเพิ่ม
const numbers: number[] = [1, 2, 3, 4, 5];
const doubleNumber = numbers.map((n) => n * 2);
console.log(doubleNumber);
console.log(numbers);
interface User {
id: number;
name: string;
}
const users: User[] = [
{ id: 1, name: "Joey" },
{ id: 2, name: "Sara" },
];
const names = users.map((user) => user.name);
console.log(names);
ผลรันจริง:
[ 2, 4, 6, 8, 10 ]
[ 1, 2, 3, 4, 5 ]
[ 'Joey', 'Sara' ]
บรรทัดที่สองสำคัญกว่าที่คิด — numbers ยังเป็น [1, 2, 3, 4, 5] เหมือนเดิมทุกตัว
ส่วนบรรทัดสุดท้ายคือท่าที่คุณจะใช้บ่อยที่สุดในชีวิตจริง:
ได้ array ของ object ก้อนใหญ่จาก API มา แล้วดึงออกมาเฉพาะช่องที่จะเอาไปแสดง
สังเกตด้วยว่า type เปลี่ยนไปได้ — users เป็น User[]
แต่ names กลายเป็น string[] โดยที่เราไม่ต้องเขียน type กำกับเลย
TypeScript อ่านจากสิ่งที่ callback คืนให้เอง
คุณเดินเข้าร้านถ่ายเอกสารพร้อมเอกสาร 5 แผ่น แล้วบอกว่า "ขยาย 200% ให้หน่อย"
สิ่งที่ได้กลับมาคือ ซองใหม่ที่มีสำเนา 5 แผ่น (ไม่ใช่ 4 ไม่ใช่ 6)
และต้นฉบับ 5 แผ่นเดิมก็คืนมาครบ ไม่มีรอยอะไรเลย
"ขยาย 200%" คือ callback · เอกสาร 5 แผ่น คือ array เดิม · ซองสำเนา คือ array ใหม่ที่ map คืนให้
ร้านไม่ได้ไปเขียนทับต้นฉบับของคุณ — นี่คือหัวใจของคำว่า functional
ที่ไม่เหมือนกัน: ที่ร้านจริง ถ้าคุณสั่งไม่ชัด พนักงานจะเงยหน้าถามก่อน
แต่ map ไม่ถาม — ถ้า callback ของคุณไม่ได้คืนอะไรกลับไป
มันก็ยินดีคืนซองที่มีกระดาษเปล่า 5 แผ่นให้คุณอย่างสุภาพ แล้วไม่บ่นสักคำ
(กับดักข้อ 2 ในหัวข้อ 7 คืออันนี้เป๊ะ ๆ)
3 · filter — คัดออก ได้กองใหม่ที่สั้นลง
filter ก็เดินทีละชิ้นเหมือนกัน แต่ callback ของมันไม่ได้ตอบว่า "แปลงเป็นอะไร"
มันตอบว่า "เอาชิ้นนี้ไหม เอา/ไม่เอา"
ชิ้นไหนตอบว่าเอา ก็ถูกหยิบใส่กองใหม่ ทั้งชิ้นแบบเดิม ไม่ถูกแปลงอะไรเลย
const examScores: number[] = [45, 72, 88, 55, 91];
const passing = examScores.filter((score: number) => score >= 60);
console.log(passing);
console.log(examScores);
ผลรันจริง:
[ 72, 88, 91 ]
[ 45, 72, 88, 55, 91 ]
type ไม่เปลี่ยน — เข้าไป number[] ออกมาก็ number[]
ต่างกันแค่จำนวนชิ้น นี่คือความต่างที่ชัดที่สุดระหว่าง filter กับ map
เอาทรายหนึ่งกำมือเทลงตะแกรง — เม็ดที่เล็กพอจะลอดลงไปอยู่ในถาดล่าง
เม็ดที่ใหญ่ค้างอยู่บนตะแกรง
เม็ดทรายที่ลอดลงไป ยังเป็นเม็ดทรายเม็ดเดิมทุกประการ ไม่ได้ถูกบด ไม่ได้ถูกย้อมสี
ตะแกรงคัด อย่างเดียว
ส่วน map คือเครื่องบดกาแฟ — เมล็ดเข้าไปกี่เม็ด ผงออกมาเท่าเดิม
แต่สภาพเปลี่ยนไปแล้ว
ตะแกรง = filter (จำนวนเปลี่ยน สภาพเดิม) · เครื่องบด = map (จำนวนเดิม สภาพเปลี่ยน)
ที่ไม่เหมือนกัน: ตะแกรงจริงทำให้ทรายกองเดิมหายไป
เม็ดที่ร่อนแล้วอยู่คนละถาด กองเดิมไม่เหลือแล้ว
แต่ filter ไม่ได้ย้ายของ มัน "ลอก" ของที่ผ่านเงื่อนไขไปกองใหม่ —
examScores ยังมีครบ 5 ตัวเหมือนตอนแรก คุณจึงเอาไปใช้ต่อได้อีกกี่รอบก็ได้
(ดูบรรทัดที่สองของผลรันข้างบน)
4 · reduce — ยุบทั้งกองให้เหลือค่าเดียว
reduce คือตัวที่คนกลัวที่สุด เพราะมันรับ สองอย่าง ไม่ใช่อย่างเดียว:
- callback ที่รับ 2 พารามิเตอร์ — ค่าที่สะสมมาถึงตอนนี้ กับ ชิ้นที่กำลังเดินถึง
- ค่าเริ่มต้น — ตัวที่บอกว่า "ก่อนเดินชิ้นแรก ค่าสะสมคืออะไร" (คือ
0ในตัวอย่างล่าง)
const cart = [
{ item: "Book", price: 15 },
{ item: "Pen", price: 3 },
{ item: "Bag", price: 40 },
];
const total = cart.reduce((sum, product) => sum + product.price, 0);
console.log(total);
ผลรันจริง: 58
ลองไล่ในหัวทีละรอบ จะเห็นว่ามันไม่มีอะไรลึกลับเลย:
และค่าที่สะสมไม่จำเป็นต้องเป็นตัวเลข — จะเป็นอะไรก็ได้ ตัวอย่างในสไลด์คือการจัดกลุ่มคำตามตัวอักษรตัวแรก โดยให้ค่าสะสมเป็น object:
const words = ["apple", "ant", "banana", "avocado", "blueberry"];
const grouped = words.reduce<Record<string, string[]>>((acc, word) => {
const letter = word[0];
acc[letter] = acc[letter] ? [...acc[letter], word] : [word];
return acc;
}, {});
console.log(grouped);
ผลรันจริง:
{ a: [ 'apple', 'ant', 'avocado' ], b: [ 'banana', 'blueberry' ] }
ตรง reduce<Record<string, string[]>> คือการบอก type ให้ TypeScript ตรง ๆ
ว่าค่าสะสมจะหน้าตาแบบไหน — จำเป็นมาก และเดี๋ยวหัวข้อ 7 จะรันให้ดูว่าถ้าไม่บอกจะเจออะไร
(Record<string, string[]> อ่านว่า "object ที่ key เป็น string และ value เป็น array ของ string")
ของในตะกร้าถูกหยิบผ่านเครื่องสแกนทีละชิ้น พนักงานมีเครื่องคิดเลขอยู่ในมือ
ก่อนเริ่ม เขากดเคลียร์ให้เป็น 0 ก่อน — นั่นคือ ค่าเริ่มต้น
ทุกชิ้นที่ผ่านเครื่องสแกน เขาบวกเข้าไปในเครื่องคิดเลข — นั่นคือ callback
ของหมดตะกร้า ตัวเลขบนหน้าจอคือคำตอบ — นั่นคือสิ่งที่ reduce คืนให้
ถ้าเขาลืมกดเคลียร์ ยอดของลูกค้าคนก่อนจะติดค้างอยู่ แล้วลูกค้าคุณจ่ายเกิน
นี่คือเหตุผลที่เกือบทุกครั้งคุณควรใส่ค่าเริ่มต้นให้ reduce เสมอ
ที่ไม่เหมือนกัน: พนักงานจริงถ้าลืมกดเคลียร์แล้วตะกร้าว่างเปล่า
เขาก็แค่คิดเงิน 0 บาทแล้วให้ลูกค้าเดินออกไป
แต่ reduce ที่ไม่ใส่ค่าเริ่มต้นแล้วเจอ array ว่าง —
มันโยน error ใส่หน้าคุณแล้วโปรแกรมตายทันที ไม่มีค่าอะไรคืนให้เลย
ที่แสบคือ TypeScript ไม่เตือนสักคำ ตอนคุณคอมไพล์ — หัวข้อ 7 มีของจริงให้ดู
- callback
- ฟังก์ชันที่เราส่งเข้าไปให้ฟังก์ชันอื่นเรียกแทนเรา — ก้อน
(n) => n * 2ที่ยัดใส่mapนั่นแหละคือ callback · ถาม senior ได้ว่า “callback ตัวนี้ถูกเรียกกี่ครั้ง และมันได้อะไรส่งเข้ามาบ้าง” คำถามนี้แก้ปัญหาได้เกินครึ่งของ bug ที่เกิดกับสามตัวนี้ - higher-order function
- ฟังก์ชันที่รับฟังก์ชันเป็นพารามิเตอร์ (หรือคืนฟังก์ชันออกมา) —
map·filter·reduceเป็นแบบนี้ทั้งสามตัว · พิมพ์บอก AI ได้ว่า “refactor this loop into higher-order array functions” จะได้โค้ดที่ตรงกว่าบอกว่า “ทำให้สั้นลงหน่อย” - accumulator · initial value
- พารามิเตอร์ตัวแรกของ callback ใน
reduce(ค่าที่สะสมมา) และค่าที่ใส่ไว้หลัง callback (จุดตั้งต้น) · ในโค้ดนิยมตั้งชื่อว่าacc· ถาม senior ได้ว่า “accumulator ของ reduce ตัวนี้ควรเป็น type อะไร” — เป็นคำถามที่ตอบแล้วโค้ดเขียนต่อได้เลย - predicate
- ฟังก์ชันที่ตอบได้แค่จริงหรือเท็จ — callback ของ
filterคือ predicate · พูดว่า “predicate ของ filter ตัวนี้ผิด” จะตรงกว่า “ตรงนี้กรองไม่ถูก”
5 · ต่อกันเป็น chain — จุดที่มันเริ่มคุ้ม
เพราะ map กับ filter คืน array ออกมา
คุณจึงเรียกตัวถัดไปต่อท้ายได้ทันที ไม่ต้องพักไว้ในตัวแปรกลางทาง
กลับไปที่โจทย์ในหัวข้อ 1 — "ยอดรวมของออเดอร์ที่จ่ายเงินแล้ว" — เขียนสองแบบเทียบกัน:
for loop (แบบที่คุ้นจาก Go)
let totalLoop = 0;
for (const o of orders) {
if (o.status === "paid") {
totalLoop = totalLoop + o.amount;
}
}
ต้องอ่านครบ 5 บรรทัดก่อนถึงจะรู้ว่ามันคำนวณอะไร
chain
const totalPaid = orders
.filter((o) => o.status === "paid")
.map((o) => o.amount)
.reduce((sum, amount) => sum + amount, 0);
อ่านจากบนลงล่างได้เป็นประโยค: คัดที่จ่ายแล้ว → เอาเฉพาะยอด → บวกรวม
เอา orders ก้อนเดิมจากหัวข้อ 1 (4 ออเดอร์ · จ่ายแล้ว 250 กับ 150) มารันทั้งสองแบบในไฟล์เดียวกัน
แล้ว console.log ออกมาทั้งคู่ ได้ผลจริงว่า:
400
400
4
{ id: 1, status: 'paid', amount: 250 }
สองบรรทัดแรกคือคำตอบของสองวิธี — ตรงกัน
บรรทัดที่ 3 กับ 4 คือ orders.length และ orders[0] ที่ผมพิมพ์ออกมาหลังคำนวณเสร็จ:
ยังครบ 4 ชิ้น และชิ้นแรกยังเป็นก้อนเดิมทุกช่อง
for เดินผ่านข้อมูล 1 รอบ · chain 3 ชั้นเดิน 3 รอบ
และสร้าง array กลางทางเพิ่มมา 2 ก้อน
กับข้อมูลหลักร้อยหลักพันชิ้นบนหน้าเว็บ คุณวัดความต่างไม่ได้เลย —
ให้เลือกอันที่คนอ่านรู้เรื่องกว่า
แต่ถ้าวันไหนเจอข้อมูลหลักแสนชิ้นแล้ววัดได้จริงว่าช้า ค่อยเปลี่ยนกลับเป็น for
แล้วเขียน comment บอกไว้ว่าเปลี่ยนเพราะวัดมาแล้ว ไม่ใช่เพราะเดา
6 · ทำไมถึงเรียกว่า functional
คำว่า functional ในบริบทนี้ไม่ได้แปลว่า "ใช้ฟังก์ชันเยอะ" มันแปลว่า "ทำงานแบบฟังก์ชันคณิตศาสตร์" คือ รับของเข้า → คืนของใหม่ออกมา → ไม่ไปยุ่งกับของที่รับเข้ามา
พิสูจน์ด้วยการรันจริง:
const scores = [45, 72, 88];
const passing = scores.filter((s) => s >= 60);
const bonus = scores.map((s) => s + 5);
console.log(scores);
console.log(passing);
console.log(bonus);
console.log(scores === passing);
const sorted = scores.sort((a, b) => b - a); // ตัวนี้ไม่ functional!
console.log(scores);
console.log(scores === sorted);
ผลรันจริง:
[ 45, 72, 88 ]
[ 72, 88 ]
[ 50, 77, 93 ]
false
[ 88, 72, 45 ]
true
สามบรรทัดแรก: ใช้ scores ไปแล้วสองรอบ มันยังเป็น [45, 72, 88] เท่าเดิม
บรรทัดที่ 4: scores === passing ได้ false — แปลว่าเป็น
คนละก้อนในหน่วยความจำจริง ๆ ไม่ใช่แค่หน้าตาเหมือนกัน
sort() ไม่ใช่พวกเดียวกับสามตัวนี้ — มันแก้ของเดิม
ดูสองบรรทัดสุดท้ายของผลรัน: หลังเรียก scores.sort(...) แล้ว
scores กลายเป็น [88, 72, 45] ไปเลย
และ scores === sorted ได้ true — แปลว่ามันคืนก้อนเดิมกลับมา
ไม่ได้สร้างใหม่
ทั้งที่หน้าตาการเรียกเหมือนกับ map / filter เป๊ะ ๆ
bug ยอดฮิตของมือใหม่คือ sort รายการเพื่อโชว์ผลลัพธ์ แล้วข้อมูลต้นทางเรียงใหม่ไปด้วย
ทำให้หน้าอื่นที่ใช้ข้อมูลก้อนเดียวกันเพี้ยนตาม · ถ้าไม่อยากให้แก้ของเดิม ให้ใช้
[...scores].sort(...) หรือ scores.toSorted(...)
- immutable
- "ไม่ถูกแก้" — ข้อมูลที่พอสร้างแล้วไม่มีใครไปเปลี่ยนมันอีก ·
mapกับfilterปฏิบัติต่อ array ต้นทางแบบ immutable · พิมพ์บอก AI ได้ว่า “keep this array immutable — return a new array instead of mutating it” - mutate · in-place
- "แก้ของเดิมตรงนั้นเลย" —
sort·push·spliceเป็นแบบนี้ · ถาม senior ได้ว่า “method นี้ mutate ของเดิมไหม” ก่อนใช้ method ที่ไม่คุ้น จะประหยัดเวลาไล่ bug ได้เยอะมาก - side effect
- ผลข้างเคียงที่เกิดนอกเหนือจากค่าที่คืนกลับมา เช่น ไปแก้ตัวแปรข้างนอก · ยิง API · เขียนไฟล์ · พูดว่า “callback ตัวนี้มี side effect” จะสื่อกว่า “มันไปแก้ของข้างนอก”
- method chaining
- การเรียก method ต่อกันเป็นสายเพราะแต่ละตัวคืนของที่เรียกตัวถัดไปได้ · พิมพ์บอก AI ได้ว่า “chain filter → map → reduce instead of using intermediate variables”
7 · กับดัก 3 อัน ที่ต้องรันถึงจะเห็น
7.1 · reduce ที่ไม่ใส่ค่าเริ่มต้น เจอ array ว่าง = โปรแกรมตาย
reduce ใส่ค่าเริ่มต้นหรือไม่ใส่ก็ได้ — ถ้าไม่ใส่
มันจะหยิบชิ้นแรกของ array มาเป็นค่าเริ่มต้นให้เอง ซึ่งฟังดูฉลาดดี
จนกระทั่งวันที่ array นั้นว่าง (เช่น API คืน [] มาเพราะผู้ใช้ยังไม่มีข้อมูล)
const prices: number[] = [];
const total = prices.reduce((sum, p) => sum + p);
console.log(total);
คิดว่ารันแล้วได้อะไร? และ tsc --noEmit --strict จะบ่นไหม?
tsc --noEmit --strict ผ่านฉลุย — exit code เป็น 0 ไม่มี error สักบรรทัด
แต่พอ node รันจริง:
TypeError: Reduce of empty array with no initial value
at Array.reduce (<anonymous>)
at Object.<anonymous> (/…/t1-reduce-empty.js:4:22)
…
Node.js v24.14.0
โปรแกรมตายทันที exit code 1
แก้ง่ายมาก — ใส่ , 0 ต่อท้าย:
prices.reduce((sum, p) => sum + p, 0) → รันแล้วได้ 0 เรียบร้อย
ไม่ตาย
tsc เขียว · เทสที่คุณเขียนด้วยข้อมูลจำลอง 3 ชิ้นก็เขียว · deploy ขึ้นไปแล้วสวยงาม
แล้ววันที่ ผู้ใช้คนแรกที่เพิ่งสมัครใหม่ เปิดหน้าตะกร้าที่ยังว่างเปล่า — จอขาว
บทเรียนคือ: ทุกครั้งที่พิมพ์ .reduce( ให้ใส่ค่าเริ่มต้นไปเลยเป็นนิสัย
ไม่ต้องรอให้มีเหตุผล · ต้นทุนของการใส่คือตัวอักษร 3 ตัว ต้นทุนของการไม่ใส่คือหน้าเว็บพัง
7.2 · map ที่ลืม return = ได้ undefined เต็ม array
arrow function เขียนได้สองแบบ และความหมายไม่เหมือนกัน:
| เขียนแบบ | แปลว่า |
|---|---|
(n) => n * 2 | ไม่มีปีกกา → คืนค่าให้อัตโนมัติ |
(n) => { n * 2; } | มีปีกกา → เป็นบล็อกโค้ด ต้องเขียน return เอง |
const numbers = [1, 2, 3];
const doubled = numbers.map((n) => {
n * 2;
});
console.log(doubled);
คิดว่าได้อะไร? และ tsc --noEmit --strict จะจับได้ไหม?
tsc --noEmit --strict ผ่าน — exit code 0 ไม่บ่นเลยแม้แต่คำเดียว
รันจริงได้:
[ undefined, undefined, undefined ]
เพราะ callback ไม่ได้ return อะไร มันจึงคืน undefined ทุกรอบ
และ map ก็เก็บ undefined ทั้งสามตัวเรียงไว้ให้อย่างซื่อสัตย์
type ของ doubled ตอนนี้คือ void[] ไม่ใช่ number[]
TypeScript จะเริ่มบ่นก็ต่อเมื่อคุณเอาผลลัพธ์ไปใช้ต่อ —
เติมบรรทัด const big = doubled.filter((n) => n > 2); เข้าไปแล้วรัน tsc อีกรอบ ได้ error จริงว่า:
error TS2365: Operator '>' cannot be applied to types 'void' and 'number'.
ถ้าเจอคำว่า 'void' โผล่มาใน error message ทั้งที่คุณไม่เคยพิมพ์คำนี้ —
ให้ไปหาปีกกาที่ลืม return ก่อนเลย ใช่เกือบทุกครั้ง
7.3 · type ที่ reduce เดาให้ มาจากค่าเริ่มต้น ไม่ใช่จาก callback
นี่คือประโยคเดียวที่ต้องจำจากหัวข้อนี้:
ค่าเริ่มต้นเป็น type อะไร TypeScript จะบังคับให้ทั้งสายเป็น type นั้น
ไม่ใช่ดูจากสิ่งที่ callback คืนออกมา · ผลที่ตามมามีสองแบบ และแบบที่ไม่ error อันตรายกว่า
แบบที่ 1 — ใส่ค่าเริ่มต้นผิด type แล้ว TypeScript ไม่บ่นเลย
const prices = [15, 3, 40];
const total = prices.reduce((sum, price) => sum + price, "");
console.log(total);
ตั้งใจจะบวกเลข แต่พิมพ์ "" แทน 0 — tsc จะจับได้ไหม แล้วได้ค่าอะไร?
tsc --noEmit --strict ผ่าน exit code 0 ไม่มี error
รันจริงได้:
15340 string
(บรรทัดนี้คือ console.log(total, typeof total) — ค่าคือ "15340" และ type คือ string)
เกิดอะไรขึ้น: ค่าเริ่มต้นเป็น string → TypeScript สรุปว่า sum เป็น string
→ sum + price กลายเป็นการต่อสตริง ซึ่งเป็นการเขียนที่ถูกกฎหมายทุกประการ
"" + 15 + 3 + 40 = "15340" · ไม่มีใครผิด ไม่มีใครบ่น
ยอดรวมของคุณกลายเป็นการเอาราคาทุกชิ้นมาต่อกันเป็นข้อความ แล้วมันจะไปโผล่บนหน้าเว็บอย่างนั้นเลย
แบบที่ 2 — ผิดคนละทาง คราวนี้ tsc จับได้ แต่ error อ่านยาก
const names = ["Joey", "Sara", "Ann"];
const joined = names.reduce((acc, name) => acc + name + ",", 0);
ผลจริงจาก tsc --noEmit --strict:
t3c-init-num.ts(3,44): error TS2769: No overload matches this call.
The last overload gave the following error.
Type 'string' is not assignable to type 'number'.
error นี้ชี้ไปที่ตำแหน่ง callback (คอลัมน์ 44) ทั้งที่ตัวที่พิมพ์ผิดคือ 0 ตรงท้ายบรรทัด
คนที่ไม่รู้กฎข้อนี้จะนั่งแก้ callback วนไปมาอยู่นาน ทั้งที่แก้จริงคือเปลี่ยน 0 เป็น ""
พอเห็น TS2769 No overload matches this call บน reduce เมื่อไหร่ —
ให้ไปดูค่าเริ่มต้นก่อนเสมอ
แล้วเคสในหัวข้อ 4 ที่ต้องเขียน reduce<Record<string, string[]>> ล่ะ?
ถ้าตัดออกแล้วปล่อยค่าเริ่มต้นเป็น {} เฉย ๆ tsc ตอบแบบนี้จริง ๆ:
t3b-empty-obj.ts(5,3): error TS7053: Element implicitly has an 'any' type because
expression of type 'string' can't be used to index type '{}'.
No index signature with a parameter of type 'string' was found on type '{}'.
t3b-empty-obj.ts(5,17): error TS7053: Element implicitly has an 'any' type because
expression of type 'string' can't be used to index type '{}'.
No index signature with a parameter of type 'string' was found on type '{}'.
t3b-empty-obj.ts(5,35): error TS7053: Element implicitly has an 'any' type because
expression of type 'string' can't be used to index type '{}'.
No index signature with a parameter of type 'string' was found on type '{}'.
ข้อความเดียวกันเป๊ะ ขึ้นมา 3 ก้อน เพราะบรรทัดที่ 5 เขียน acc[letter] ไว้ 3 ที่
(คอลัมน์ 3 · 17 · 35) และ tsc บ่นทีละที่ — ไม่ใช่ 3 ปัญหา แต่เป็นปัญหาเดียวที่นับซ้ำ
นี่คือนิสัยของ tsc ที่ควรรู้ไว้: ตัวเลขจำนวน error ที่ขึ้นมาไม่เท่ากับจำนวนจุดที่ต้องแก้
ให้ดูว่าข้อความซ้ำกันไหมก่อนตกใจ
แปลเป็นภาษาคน: "คุณให้ค่าเริ่มต้นเป็น object เปล่า ๆ ผมเลยคิดว่ามันไม่มี key อะไรเลย
พอคุณจะเขียน key ลงไป ผมไม่รู้ว่ามันควรเป็นอะไร"
ทางแก้คือบอก type ให้มันตรง ๆ ผ่าน reduce<…> อย่างที่สไลด์เขียนไว้ นั่นเอง
- compile-time error · runtime error
- อันแรกคือที่
tscจับได้ก่อนโปรแกรมทำงาน · อันหลังคือที่ระเบิดตอนโปรแกรมทำงานอยู่จริง (กับดักข้อ 7.1 คืออันหลัง) · ถาม senior ได้ว่า “เคสนี้ TypeScript จับให้ตอน compile หรือรอไประเบิดตอน runtime” คำถามนี้แยกได้ว่าปัญหาไหนต้องเขียนเทสไว้ ปัญหาไหนปล่อยให้ compiler ดูแล - type inference
- การที่ TypeScript เดา type ให้เองโดยไม่ต้องเขียนกำกับ ·
users.map((u) => u.name)ได้string[]มาจากตัวนี้ · และเคสreduceที่ค่าเริ่มต้นเป็น""ก็คือ inference ที่ทำงานถูกต้อง แต่เดาจากข้อมูลที่เราให้ผิด · พิมพ์บอก AI ได้ว่า “what type does TypeScript infer for this variable and why” - overload
- ฟังก์ชันตัวเดียวที่มี "หน้าตาการเรียก" ได้หลายแบบ —
reduceมีทั้งแบบใส่ค่าเริ่มต้นและไม่ใส่ จึงเป็น overload · เวลาที่ทุกแบบไม่เข้าเลยtscจะขึ้นว่าTS2769 No overload matches this call· เห็น error นี้เมื่อไหร่ ให้ไล่ดูพารามิเตอร์ทีละตัว อย่าเชื่อตำแหน่งที่มันชี้อย่างเดียว
8 · แล้วเมื่อไหร่ for ยังดีกว่า
บทนี้ไม่ได้จะบอกว่า for เป็นของโบราณ — มันมีที่ที่มันชนะชัด ๆ อยู่:
| สถานการณ์ | เลือก | เพราะ |
|---|---|---|
| แปลงทุกชิ้น · คัดบางชิ้น · รวมเป็นค่าเดียว | map/filter/reduce |
ชื่อ method บอกเจตนาตั้งแต่คำแรก ไม่ต้องอ่านข้างในก็รู้ |
ต้อง break ออกกลางคัน (เจอตัวแรกที่ใช่แล้วพอ) |
for · หรือ find / some |
สามตัวนี้หยุดกลางทางไม่ได้ เดินจนจบเสมอ |
| เดินสอง array พร้อมกันด้วย index เดียวกัน | for |
เขียนตรงไปตรงมากว่าและอ่านง่ายกว่ามาก |
callback ต้อง await ทีละชิ้นตามลำดับ |
for…of |
map กับ async จะได้ array ของ Promise ไม่ใช่ค่าจริง |
| ข้อมูลหลักแสนชิ้น และวัดแล้วว่าช้าจริง | for |
เดินรอบเดียว ไม่สร้าง array กลางทาง — แต่ต้องวัดก่อน ไม่ใช่เดา |
เกณฑ์ตัดสินง่าย ๆ ที่ใช้ได้เกือบทุกครั้ง:
ถ้าอธิบายสิ่งที่กำลังทำเป็นประโยคเดียวได้ ("เอาชื่อของทุกคน") ให้ใช้ chain ·
ถ้าอธิบายไม่ได้โดยไม่พูดคำว่า "แล้วก็" ให้ใช้ for
9 · แล้วถ้าของที่มีเป็น object ไม่ใช่ array?
ปัญหาคือ map · filter · reduce เป็นของ array เท่านั้น
ถ้าในมือคุณเป็น object ก้อนเดียว มันไม่มี .map ให้เรียก — ต้องแปลงเป็น array ก่อน
ซึ่งมีสามท่ามาตรฐาน:
const config = { host: "localhost", port: 3000 };
console.log(Object.keys(config));
console.log(Object.values(config));
console.log(Object.entries(config));
ผลรันจริง:
[ 'host', 'port' ]
[ 'localhost', 3000 ]
[ [ 'host', 'localhost' ], [ 'port', 3000 ] ]
สังเกตบรรทัดสุดท้าย — Object.entries คืน array ของ array คู่
โดยตัวแรกคือ key ตัวที่สองคือ value
พอมันเป็น array แล้ว ทุกอย่างในบทนี้ใช้ได้ทันที:
const stock = { apple: 4, banana: 0, cherry: 12 };
const inStock = Object.entries(stock)
.filter(([, count]) => count > 0)
.map(([name]) => name);
console.log(inStock);
ผลรันจริง: [ 'apple', 'cherry' ]
ตรง ([, count]) คือการแกะคู่ออกมาตั้งชื่อ — ช่องแรกเว้นว่างไว้แปลว่า
"ไม่ใช้ key ตัวนี้" ส่วน ([name]) คือหยิบเฉพาะช่องแรก
ถ้าอ่านแล้วงง เขียนยาวก็ได้ ผลเหมือนกันเป๊ะ:
.filter((pair) => pair[1] > 0).map((pair) => pair[0])
🛠 ภารกิจ — เขียนแล้วพิสูจน์ด้วยเทส ไม่ใช่แค่ดูผลลัพธ์แล้วพอใจ
สร้างไฟล์ practice.ts ในโฟลเดอร์ว่าง ๆ แล้วทำตามลำดับนี้ ห้ามข้ามข้อ 4:
-
ประกาศข้อมูลนี้ไว้บนสุด:
const scores: number[] = [45, 72, 88, 55, 91]; -
เขียน chain หนึ่งบรรทัดที่ตอบว่า "คะแนนเฉลี่ยของคนที่สอบผ่าน (≥ 60)"
โดยใช้
filter+reduce· ห้ามใช้for
(คำใบ้:reduceให้ผลรวม ส่วนจำนวนคนเอาจาก.lengthของ array ที่filterคืนมา) -
ก็อปกล่องนี้ไปวางบนสุดของไฟล์ แล้วใช้มันเช็คคำตอบ — นี่คือเทสของคุณ
(เขียนเองล้วน ไม่ต้อง
npm installอะไร):function check(name: string, got: unknown, want: unknown): void { const g = JSON.stringify(got); const w = JSON.stringify(want); console.log(g === w ? `PASS ${name}` : `FAIL ${name}: got ${g} want ${w}`); } -
เขียนเทสให้ครบ 3 ข้อ — ข้อที่สามคือข้อที่คนข้ามแล้วเสียใจทีหลัง:
① ค่าเฉลี่ยที่ได้ ตรงกับที่คำนวณด้วยมือ
②scoresยังเป็น[45, 72, 88, 55, 91]ครบ 5 ตัวเหมือนเดิม หลังคำนวณเสร็จ
③ เปลี่ยนscoresเป็น[]แล้วรันใหม่ — โปรแกรมต้องไม่ตาย และต้องตอบค่าที่คุณตั้งใจให้มันตอบ (จะเป็น0ก็ได้ ขอแค่คุณเลือกเอง ไม่ใช่ปล่อยให้บังเอิญ) -
รันสองคำสั่งนี้แล้วต้องผ่านทั้งคู่:
tsc --noEmit --strict practice.tstsc --target es2022 --module nodenext --moduleResolution nodenext practice.ts && node practice.js
ตัวอย่างผลรันของกล่อง check ที่ผมทดสอบไว้แล้ว (จงใจให้ข้อสุดท้ายพัง เพื่อพิสูจน์ว่าเทสจับได้จริง
ไม่ใช่พิมพ์ PASS ออกมาทุกกรณี):
PASS filter คัดคนสอบผ่าน
PASS ของเดิมไม่ถูกแก้
FAIL ตั้งใจให้พัง: got [72,88,91] want [72,88]
เทสที่ไม่เคยเห็นมันขึ้น FAIL เลย คือเทสที่คุณยังไม่รู้ว่ามันทำงานหรือเปล่า —
ลองทำให้มันพังหนึ่งครั้งเสมอ ก่อนจะเชื่อว่ามันผ่าน
ทดสอบความเข้าใจ
1. const total = prices.reduce((sum, p) => sum + p); โดยที่ prices เป็น number[] ที่ตอนนั้นว่างเปล่า — เกิดอะไรขึ้น?
ผมรันจริงทั้งสองขั้น: tsc --noEmit --strict ได้ exit code 0 ไม่มี error
แล้ว node ตายด้วย TypeError: Reduce of empty array with no initial value exit code 1
ตัวเลือกที่ 1 คือสิ่งที่เราอยากให้เกิด แต่ไม่เกิด — เพราะ type signature ของ reduce
อนุญาตให้ไม่ใส่ค่าเริ่มต้นได้ตามสเปกของภาษา TypeScript จึงไม่มีเหตุผลให้บ่น
ตัวเลือกที่ 2 กับ 4 ตั้งอยู่บนความเชื่อว่า "ว่างเปล่าแล้วมันคงคืนค่าอะไรสักอย่างให้" —
ทั้งคู่ผิด เพราะเมื่อไม่มีค่าเริ่มต้นและไม่มีสมาชิกให้หยิบ มันไม่มีอะไรจะคืน จึงเลือกโยน error แทน
จุดที่ต้องจำ: ทุกครั้งที่พิมพ์ .reduce( ให้ใส่ค่าเริ่มต้นเป็นนิสัย —
ต้นทุนคือตัวอักษร 3 ตัว ส่วนต้นทุนของการไม่ใส่คือหน้าเว็บพังในวันที่ข้อมูลว่าง
2. const doubled = numbers.map((n) => { n * 2; }); โดย numbers คือ [1, 2, 3] — doubled คืออะไร?
รันจริงได้ [ undefined, undefined, undefined ] และ tsc --noEmit --strict exit code 0
ตัวเลือกที่ 1 คือความเข้าใจผิดเรื่อง arrow function — การคืนค่าอัตโนมัติเกิดเฉพาะตอน
ไม่มีปีกกา เท่านั้น พอใส่ { } มันกลายเป็นบล็อกโค้ดที่ต้องเขียน return เอง
ตัวเลือกที่ 3 ผิดตรงจำนวน — map คืน array ที่ยาวเท่าต้นฉบับเสมอ
ไม่มีทางได้ [] จาก array 3 ชิ้น (ตัวที่ทำให้สั้นลงได้คือ filter)
ตัวเลือกที่ 4 ผิดเพราะ void เป็น return type ที่ถูกกฎหมาย — TypeScript เพิ่งมาบ่นตอนเอาไปใช้ต่อ
ด้วย error TS2365: Operator '>' cannot be applied to types 'void' and 'number'.
จุดที่ต้องจำ: เห็นคำว่า 'void' ใน error message ทั้งที่ไม่เคยพิมพ์คำนี้เอง
ให้ไปหาปีกกาที่ลืม return ก่อนเลย
3. const total = prices.reduce((sum, price) => sum + price, ""); โดย prices คือ [15, 3, 40] — ผลเป็นยังไง?
tsc --noEmit --strict ได้ exit code 0 และ console.log(total, typeof total)
รันจริงได้ 15340 string
เหตุผล: ค่าเริ่มต้นเป็น string → TypeScript สรุปว่า sum เป็น string
→ sum + price คือการต่อสตริง ซึ่งถูกกฎหมายทุกประการ ไม่มีอะไรให้บ่น
ตัวเลือกที่ 1 คือกับดักตัวจริง เพราะมันคือสิ่งที่ควรเกิดตามสัญชาตญาณ — TS2769 เกิดจริง
แต่กับกรณีกลับด้าน (ใส่ 0 ให้ callback ที่คืน string) ไม่ใช่กรณีนี้
ตัวเลือกที่ 2 กับ 3 เดาว่าจะมีการแปลง type ระหว่างทาง — JavaScript ไม่ได้ทำอย่างนั้น
เมื่อฝั่งซ้ายของ + เป็น string มันเลือกต่อสตริงเสมอ ไม่ใช่บวกเลขและไม่ใช่ NaN
จุดที่ต้องจำ: type ของ reduce มาจากค่าเริ่มต้น ไม่ใช่จากสิ่งที่ callback คืน
เจอผลลัพธ์แปลก ๆ จาก reduce เมื่อไหร่ ให้อ่านตัวสุดท้ายหลังเครื่องหมายจุลภาคก่อน
4. งานคือ "หาออเดอร์แรกที่ยอดเกิน 1000 แล้วหยุดค้นทันที" ในรายการที่มีเป็นแสนแถว — ควรใช้อะไร?
สามตัวในบทนี้ เดินจนจบ array เสมอ ไม่มีทางหยุดกลางคัน — นี่คือข้อจำกัดที่แลกมากับความอ่านง่าย
ตัวเลือกที่ 2 ผิดตรงคำว่า "ฉลาดพอที่จะหยุดเอง" — filter เดินครบทุกแถวเสมอ
แล้วค่อยคืน array ของทุกตัวที่ผ่าน แสนแถวก็เดินแสนรอบ ทั้งที่คำตอบอาจอยู่แถวที่ 3
ตัวเลือกที่ 3 สับสนระหว่าง return ของ callback กับ break ของ loop —
return ใน callback แค่จบรอบนั้นรอบเดียว แล้ว map ก็เรียกรอบถัดไปต่อ
ตัวเลือกที่ 4 หยุดได้จริงทางเทคนิค แต่ไม่ใช่วิธีมาตรฐานแน่นอน —
ใช้ error เป็นเครื่องมือควบคุมการไหลของโปรแกรม ทำให้คนอ่านโค้ดต่อแยกไม่ออกว่าอันไหนคือ bug จริง
จุดที่ต้องจำ: "หยุดกลางทางได้ไหม" คือคำถามแรกที่ควรถามก่อนเลือกระหว่าง chain กับ for —
ถ้าต้องหยุด ให้ใช้ find · some · every หรือ for ไปเลย
💬 ติดตรงไหนถามได้ — ถ้ารันตามในบทแล้วได้ผลไม่ตรงกับที่เขียนไว้
ให้ส่งมาทั้งโค้ดที่รัน · คำสั่งที่ใช้ · และข้อความที่ขึ้นเต็ม ๆ
สามอย่างนี้ครบเมื่อไหร่ ตอบได้เกือบทุกครั้งโดยไม่ต้องถามกลับ
และคำถามที่คุ้มที่สุดที่ถามได้กับบทนี้คือ "เคสนี้ควรใช้ chain หรือ for ดี" —
เพราะมันไม่มีคำตอบเดียว มันขึ้นกับว่าใครจะมาอ่านโค้ดคุณต่อ
อ้างอิง
- MDN —
Array.prototype.map()— อ่านหัวข้อ Parameters ให้เห็นว่า callback ได้รับ 3 อย่าง (element·index·array) ไม่ใช่อย่างเดียว — นี่คือเหตุผลที่["1","2","3"].map(parseInt)ได้[1, NaN, NaN](ผมรันแล้ว ได้อย่างนั้นจริง) - MDN —
Array.prototype.filter()— อ่านหัวข้อ Description ย่อหน้าแรก ที่บอกว่ามันคืน shallow copy — คำนี้อธิบายว่าทำไม array ต้นทางถึงไม่ถูกแตะ - MDN —
Array.prototype.reduce()— อ่านหัวข้อ Exceptions ซึ่งเขียนไว้ตรง ๆ ว่าTypeErrorจะถูกโยนเมื่อ array ว่างและไม่มีค่าเริ่มต้น · แล้วอ่าน Description ต่อเพื่อดูว่า ถ้าไม่ใส่ค่าเริ่มต้น มันหยิบสมาชิกตัวแรกมาใช้แทนยังไง - TypeScript Handbook — More on Functions —
อ่านหัวข้อ Function Type Expressions และ Generic Functions
เพื่อเข้าใจว่าทำไมถึงเขียน
reduce<Record<string, string[]>>ได้ และมันไปบอกอะไรกับ compiler - MDN —
Object.entries()— อ่าน Description ประโยคแรก ที่บอกว่ามันคืน array ของคู่[key, value]แล้วดูตัวอย่างท้ายหน้าที่ใช้ร่วมกับfor…of— เทียบกับท่า chain ในหัวข้อ 9 ของบทนี้ว่าอันไหนอ่านง่ายกว่าสำหรับคุณ - MDN —
Array.prototype.sort()— อ่านประโยคแรกของ Description ที่บอกว่ามันเรียง in place แล้วเลื่อนไปดูtoSorted()ในตารางท้ายหน้า — คือเวอร์ชันที่ไม่แก้ของเดิม