บทเรียน 67 · Frontend · Functional Programming with TypeScript

map, filter, reduce — สามตัวที่ใช้ทุกวัน

~20 นาที · ควรผ่าน บทเรียน 66 (แปลงข้อมูล) มาก่อน · ถ้าเคยเขียน for ใน Go มาแล้วจะอ่านบทนี้สบายมาก

ถ้าคุณจำอะไรจากกลุ่มบท Functional Programming ได้แค่บทเดียว ให้เป็นบทนี้
สามคำนี้ — map · filter · reduce — คือสิ่งที่คุณจะพิมพ์แทบทุกวัน ตอนเอาข้อมูลจาก API มาแสดงบนหน้าเว็บ
ทั้งสามตัวทำงานบน array และไม่แก้ของเดิมสักตัวเดียว มันคืน array ก้อนใหม่ให้เสมอ ซึ่งเป็นเหตุผลที่คนเรียกสไตล์นี้ว่า functional
บทนี้จะพาไล่ทีละตัว ต่อกันเป็น chain แล้วรันกับดัก 3 อันให้ดูของจริง รวมถึงอันที่ TypeScript ไม่เตือนเลย แล้วไประเบิดตอนโปรแกรมทำงานจริง

🧰 ของที่ใช้ตรวจในบทนี้ (รันวันที่ 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 เรื่องนั้นออกจากกัน ให้แต่ละเรื่องมีชื่อของตัวเอง:

อยากทำอะไรใช้ตัวไหนได้อะไรกลับมา
แปลงของทุกชิ้นเป็นของใหม่maparray ใหม่ ยาวเท่าเดิม
คัดเอาเฉพาะชิ้นที่เข้าเงื่อนไขfilterarray ใหม่ สั้นลงหรือเท่าเดิม
ยุบทั้งกองให้เหลือค่าเดียว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 คือตัวที่คนกลัวที่สุด เพราะมันรับ สองอย่าง ไม่ใช่อย่างเดียว:

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

ลองไล่ในหัวทีละรอบ จะเห็นว่ามันไม่มีอะไรลึกลับเลย:

เริ่มต้น sum = 0 รอบ 1 Book (15) sum = 0 + 15 = 15 รอบ 2 Pen (3) sum = 15 + 3 = 18 รอบ 3 Bag (40) sum = 18 + 40 = 58 ──── คืนออกมา 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 ชิ้น และชิ้นแรกยังเป็นก้อนเดิมทุกช่อง

⚠️ chain ไม่ได้แปลว่าเร็วกว่า

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 เรียบร้อย ไม่ตาย

⚠️ นี่คือประเภท bug ที่แย่ที่สุด

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:

  1. ประกาศข้อมูลนี้ไว้บนสุด:
    const scores: number[] = [45, 72, 88, 55, 91];
  2. เขียน chain หนึ่งบรรทัดที่ตอบว่า "คะแนนเฉลี่ยของคนที่สอบผ่าน (≥ 60)" โดยใช้ filter + reduce · ห้ามใช้ for
    (คำใบ้: reduce ให้ผลรวม ส่วนจำนวนคนเอาจาก .length ของ array ที่ filter คืนมา)
  3. ก็อปกล่องนี้ไปวางบนสุดของไฟล์ แล้วใช้มันเช็คคำตอบ — นี่คือเทสของคุณ (เขียนเองล้วน ไม่ต้อง 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}`);
    }
  4. เขียนเทสให้ครบ 3 ข้อ — ข้อที่สามคือข้อที่คนข้ามแล้วเสียใจทีหลัง:
    ① ค่าเฉลี่ยที่ได้ ตรงกับที่คำนวณด้วยมือ
    ② scores ยังเป็น [45, 72, 88, 55, 91] ครบ 5 ตัวเหมือนเดิม หลังคำนวณเสร็จ
    ③ เปลี่ยน scores เป็น [] แล้วรันใหม่ — โปรแกรมต้องไม่ตาย และต้องตอบค่าที่คุณตั้งใจให้มันตอบ (จะเป็น 0 ก็ได้ ขอแค่คุณเลือกเอง ไม่ใช่ปล่อยให้บังเอิญ)
  5. รันสองคำสั่งนี้แล้วต้องผ่านทั้งคู่:
    tsc --noEmit --strict practice.ts
    tsc --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 ดี" — เพราะมันไม่มีคำตอบเดียว มันขึ้นกับว่าใครจะมาอ่านโค้ดคุณต่อ

อ้างอิง