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

ใส่ type ให้ฟังก์ชัน — parameter, return, arrow function

~20 นาที · ควรผ่านบทเรียน 64 (interface กับ type) มาก่อน · ไม่ต้องเคยเขียน JavaScript มาก่อน

บทนี้มาจากสไลด์ TypeScript หน้า 24–28 ซึ่งเป็นชุด "Function in TypeScript"
สไลด์วาดลูกศรชี้ว่าตรงไหนคือ parameter ตรงไหนคือ return type — ซึ่งถูกหมด แต่โค้ดตัวอย่างในหน้า 28 มีอยู่สองก้อน และผมรันแล้วมันไม่ผ่านทั้งสองก้อน
ที่มันไม่ผ่านนี่แหละคือของดี เพราะสอง error นั้นคือกับดักที่คุณจะเจอซ้ำทุกอาทิตย์: ค่าที่อาจเป็น undefined กับ ชื่อ parameter ที่พิมพ์ไม่ตรง
ผมเอาโค้ดจากสไลด์มารันจริงทุกก้อน แปะ error จริงไว้ให้ แล้วแก้ให้ดูทีละบรรทัด

🧰 ของที่ใช้ตรวจในบทนี้

tsc  Version 7.0.2
node v24.14.0

ทุก error ในบทนี้ก็อปมาจากผลรัน tsc --noEmit --strict ไฟล์.ts ตัวจริง ทั้งเลข TSxxxx · ข้อความ · และเลขบรรทัด/คอลัมน์ในวงเล็บ
ทุกค่าที่บอกว่า "พิมพ์ออกมาได้" มาจากการคอมไพล์แล้วรันด้วย node จริง — ไม่มีอันไหนเขียนจากความจำ คุณก็อปโค้ดไปรันซ้ำได้ทุกก้อน

ฟังก์ชันคืออะไร ในภาษาที่ไม่ต้องมีศัพท์

ฟังก์ชันคือเครื่องหนึ่งเครื่อง ที่คุณป้อนของเข้าไปแล้วมันคายของออกมา
ตอนเขียน JavaScript ธรรมดา คุณบอกได้แค่ว่า "เครื่องนี้ชื่อ sum และรับของสองชิ้น" แต่บอกไม่ได้ว่าของที่รับต้องเป็นอะไร และของที่คายออกมาจะเป็นอะไร

TypeScript เพิ่มที่ว่างสามจุดให้คุณเขียนสิ่งนี้ลงไป — และนี่คือทั้งหมดที่สไลด์หน้า 26 วาดลูกศรชี้:

function sum(num1: number, num2: number): number {
  return num1 + num2;
}
//            ↑ ชนิดของ       ↑ ชนิดของ      ↑ ชนิดของ
//              ของเข้าชิ้นที่ 1  ของเข้าชิ้นที่ 2   ของที่คายออกมา

เขียนสามจุดนี้เสร็จ คุณได้อะไรกลับมาทันที: คนอื่นอ่านบรรทัดแรกบรรทัดเดียวก็ใช้ฟังก์ชันนี้เป็น ไม่ต้องไล่อ่านข้างในเลย และเครื่องมือ (tsc) จะเริ่มด่าแทนคุณเมื่อมีใครใช้ผิด

ผมคอมไพล์แล้วรันด้วย node จริง — sum(2, 3) ได้:

5
🧰 เทียบให้เห็นภาพ — ป้ายข้างเครื่องคั้นน้ำผลไม้

ร้านน้ำผลไม้มีเครื่องคั้นตั้งอยู่ ถ้าไม่มีป้าย พนักงานใหม่ต้องเดาเอง: ใส่ส้มทั้งลูกได้ไหม ต้องปอกก่อนหรือเปล่า ที่ออกมาคือน้ำหรือเป็นเนื้อ เดาผิดทีก็รู้ตอนของออกมาแล้ว — ซึ่งสายไปแล้ว

ถ้ามีป้ายติดไว้ว่า "รับ: ส้มปอกเปลือก 2 ลูก · ได้: น้ำส้ม 1 แก้ว" พนักงานใหม่ทำถูกตั้งแต่ครั้งแรก โดยไม่ต้องเปิดฝาเครื่องดูข้างใน
สามจุดที่คุณเขียน type ลงไป คือป้ายใบนั้น

ที่ไม่เหมือนกัน: ป้ายข้างเครื่องเป็นแค่กระดาษ ใครจะฝืนยัดส้มทั้งเปลือกเข้าไปก็ได้ ป้ายห้ามไม่ได้
แต่ type ใน TypeScript ห้ามได้จริง — มันไม่ยอมให้คุณคอมไพล์ผ่านตั้งแต่แรก
ในทางกลับกัน ป้ายกระดาษอยู่กับเครื่องตลอดไป ส่วน type หายไปตอนคอมไพล์ ไฟล์ .js ที่ได้ไม่มี type เหลือแม้แต่ตัวเดียว ดังนั้นถ้ามีข้อมูลแปลก ๆ วิ่งเข้ามาตอนรันจริง (เช่นจาก API) type ช่วยอะไรไม่ได้เลย

🗣 ศัพท์ที่เพิ่งเรียนไป
parameter / argument
parameter คือชื่อช่องที่ประกาศไว้ตอนเขียนฟังก์ชัน (num1, num2) · argument คือของจริงที่ใส่เข้าไปตอนเรียก (2, 3) · ถาม senior ได้ว่า “ฟังก์ชันนี้ควรรับเป็น parameter หลายตัว หรือรับ object ก้อนเดียว” พอเกิน 3 ช่องเมื่อไหร่ คำถามนี้มักเปลี่ยนคำตอบ
return type
ชนิดของค่าที่ฟังก์ชันคายออกมา เขียนไว้หลังวงเล็บปิดคั่นด้วย : · พิมพ์บอก AI ได้ว่า “annotate the return type of every exported function” เป็นคำสั่งที่ตรงกว่าการบอกว่า “ใส่ type ให้หน่อย”
type annotation
ตัวหนังสือที่คุณเขียนกำกับไว้เองว่าอะไรเป็นชนิดไหน (: number) · คู่ตรงข้ามคือ type inference ที่ปล่อยให้ TypeScript เดาเอง — ถาม senior ได้ว่า “ที่นี่มีกฎว่าต้อง annotate ตรงไหนบ้าง” แต่ละทีมมีเส้นแบ่งไม่เหมือนกัน และมันมักเขียนอยู่ในไฟล์ lint ไม่ใช่ในหัวใคร

เขียนอีกแบบ: arrow function

สไลด์หน้า 27 เอาฟังก์ชันเดิมเป๊ะ ๆ มาเขียนใหม่ด้วยอีกทรงหนึ่ง เรียกว่า arrow function สิ่งที่เปลี่ยนคือรูปประโยค ไม่ใช่ความสามารถ:

ทรงที่ 1 — function declaration

function sum(num1: number, num2: number): number {
  return num1 + num2;
}

ขึ้นต้นด้วยคำว่า function แล้วตามด้วยชื่อ

ทรงที่ 2 — arrow function

const sum = (num1: number, num2: number): number => {
  return num1 + num2;
};

เป็น "ค่า" ก้อนหนึ่งที่เอาไปเก็บใน const

ผมใส่สองอันนี้ในไฟล์เดียวกัน คอมไพล์แล้วรันจริง เรียกทั้งคู่ด้วย (2, 3) ได้:

5
5

ตำแหน่งของ type อยู่ที่เดิมทั้งสองทรง — หลังชื่อ parameter หนึ่งจุด และหลังวงเล็บปิดอีกหนึ่งจุด ต่างกันแค่ว่าทรงที่ 2 ไม่มีคำว่า function แล้วเอา => มาคั่นก่อนปีกกา

แล้วจะเลือกอันไหน? เอาจริง ๆ ในโค้ดฝั่ง frontend สมัยนี้คุณจะเห็น arrow function เยอะกว่ามาก เหตุผลใหญ่ข้อหนึ่งคือเรื่อง this ซึ่งผมเก็บไว้ท้ายบทเพราะมันต้องรันให้ดู

TypeScript เดา return type ให้เองได้ — แล้วจะเขียนกำกับไปทำไม

ถ้าคุณไม่เขียน : number ตรงหลังวงเล็บ TypeScript ก็ยังรู้ว่าฟังก์ชันคืนอะไร มันอ่านจากบรรทัด return เอาเอง เรื่องนี้จริง ไม่ได้หลอก

คำถามที่ควรถามจึงไม่ใช่ "มันเดาได้ไหม" แต่คือ "ตอนโค้ดผิด มันจะบอกเราตรงไหน" — และคำตอบของสองแบบนี้ไม่เหมือนกัน

ผมเขียนฟังก์ชันที่ตั้งใจให้ผิด: ชื่อบอกว่าคำนวณราคา แต่ดันเผลอต่อสตริง

แบบ A — ปล่อยให้เดาเอง

// infer-a.ts
function priceOf(qty: number) {
  return "" + qty * 20;
}

const total: number = priceOf(3);
console.log(total);

แบบ B — เขียนกำกับไว้

// infer-b.ts
function priceOf(qty: number): number {
  return "" + qty * 20;
}

const total: number = priceOf(3);
console.log(total);

สั่ง tsc --noEmit --strict ทีละไฟล์ ได้ผลนี้:

=== A (ไม่เขียน return type) ===
infer-a.ts(6,7): error TS2322: Type 'string' is not assignable to type 'number'.

=== B (เขียน return type) ===
infer-b.ts(3,3): error TS2322: Type 'string' is not assignable to type 'number'.

เลข error เดียวกัน ข้อความเดียวกัน — แต่คนละบรรทัด
· แบบ A ชี้ที่ บรรทัด 6 คือบรรทัด const total = priceOf(3) ที่เรียกใช้
· แบบ B ชี้ที่ บรรทัด 3 คือบรรทัด return ที่เขียนผิด
(เลขบรรทัดนับรวมบรรทัดคอมเมนต์ // infer-a.ts ที่อยู่บนสุดด้วย — ถ้าคุณก็อปโค้ดไปโดยไม่เอาคอมเมนต์ เลขจะเลื่อนขึ้นหนึ่ง เป็น 5 กับ 2)

⚠️ ทำไมเรื่องนี้ใหญ่กว่าที่คิด

ในไฟล์เล็ก ๆ 7 บรรทัด แบบ A กับ B ไม่ต่างกันเลย เดินไปสามบรรทัดก็เจอ
แต่ของจริง ฟังก์ชันหนึ่งตัวมีคนเรียก 20 ที่ กระจายอยู่ 8 ไฟล์
· แบบ A: คุณจะเห็น error 20 อัน ในไฟล์ที่ไม่ได้ผิด แล้วต้องไล่ย้อนกลับไปหาต้นตอเอง
· แบบ B: คุณเห็น error อันเดียว ตรงจุดที่พิมพ์ผิดพอดี
ยิ่งฟังก์ชันถูกใช้เยอะ การเขียน return type กำกับยิ่งคุ้ม — ไม่ใช่เพราะ TypeScript เดาไม่เป็น แต่เพราะมันเปลี่ยน "ที่เกิดเหตุ" ให้ตรงกับ "ที่ผิดจริง"

parameter ที่ไม่ต้องใส่ก็ได้ — เครื่องหมาย ?

บางทีคุณอยากให้ช่องหนึ่งใส่หรือไม่ใส่ก็ได้ TypeScript ให้เติม ? ท้ายชื่อ parameter — และสไลด์หน้า 28 ยกตัวอย่างไว้แบบนี้

ผมพิมพ์ตามสไลด์เป๊ะทุกตัวอักษร แล้วสั่ง tsc --noEmit --strict:

// slide28a.ts — โค้ดจากสไลด์หน้า 28 ก้อนบน
const sum = (num1: number = 0, num2?: number): number => {
  return num1 + num2
}
slide28a.ts(3,17): error TS18048: 'num2' is possibly 'undefined'.

สไลด์ไม่ผ่าน และเหตุผลที่ไม่ผ่านคือหัวใจของเครื่องหมาย ? ทั้งอัน:
พอคุณเขียน num2?: number ชนิดจริงของ num2 ไม่ใช่ number แต่เป็น number | undefined — คือ "เป็นตัวเลข หรือ ไม่มีค่าเลย" อย่างใดอย่างหนึ่ง
เอาสิ่งที่อาจจะ "ไม่มีค่าเลย" ไปบวก TypeScript เลยยกมือห้ามไว้ก่อน

ทายก่อนดูเฉลย — ถ้าเราดื้อ ไม่ยอมเช็ค แล้วบังคับให้มันคอมไพล์ผ่านไปให้ได้ (ด้วยการยัด as) แล้วรันจริงด้วย node:

const sum = (num1: number = 0, num2?: number): any => {
  return num1 + (num2 as any);
};

console.log(sum(2, 3));
console.log(sum(2));

บรรทัดที่สองพิมพ์อะไรออกมา? — โปรแกรมจะพังไปเลย · ได้ 2 เฉย ๆ · หรือได้อย่างอื่น?

5
NaN

ไม่พัง และไม่ได้ 2 — ได้ NaN ซึ่งย่อมาจาก "Not a Number"
เพราะตอนรันจริง num2 มีค่าเป็น undefined และ 2 + undefined ใน JavaScript ไม่ใช่ error — มันคือ NaN

นี่คือเหตุผลที่ error TS18048 มีอยู่บนโลก
NaN เป็นค่าที่เดินทางต่อได้: เอาไปบวกต่อก็ยังได้ NaN เอาไปคูณต่อก็ยัง NaN เอาไปใส่ตะกร้าสินค้าก็ยัง NaN
กว่าคุณจะเห็นมันคือตอนราคารวมบนหน้าจอขึ้นคำว่า NaN ให้ลูกค้าดู ห่างจากบรรทัดที่ผิดจริงไปแล้วสิบไฟล์

ทางแก้ไม่ใช่ยัด as แต่คือเช็คก่อนใช้ ซึ่งอยู่ในกล่องถัดไป

วิธีแก้ที่ถูกคือถามก่อนว่ามีค่าไหม แล้วค่อยใช้:

const sum = (num1: number = 0, num2?: number): number => {
  if (num2 === undefined) {
    return num1;
  }
  return num1 + num2;   // ตรงนี้ TypeScript รู้แล้วว่า num2 เป็น number แน่ ๆ
};

console.log(sum(2, 3));
console.log(sum(2));
console.log(sum());

tsc --noEmit --strict เงียบสนิท (ไม่มี error) และรันด้วย node ได้:

5
2
0
✅ สิ่งที่เพิ่งเกิดขึ้นตรง if นั้นมีชื่อเรียก

หลังบรรทัด if (num2 === undefined) return ... ผ่านไป TypeScript ตัดคำว่า undefined ออกจากชนิดของ num2 ให้เอง บรรทัดล่างจึงบวกได้โดยไม่ต้องยัด as อะไรทั้งสิ้น
มันเรียกว่า narrowing — คุณจะได้ยินคำนี้บ่อยมากในบทถัด ๆ ไป
จำหลักไว้สั้น ๆ: อย่าโกหก type ด้วย as — เขียน if ให้มันเห็นด้วยตาตัวเอง

parameter ที่มีค่าตั้งต้น — = ค่า

อีกวิธีที่ทำให้ "ไม่ใส่ก็ได้" คือใส่ค่าตั้งต้นไว้ ด้วยเครื่องหมาย = วิธีนี้ต่างจาก ? ตรงจุดสำคัญจุดเดียว: ค่าไม่มีวันเป็น undefined จึงไม่ต้องเช็คก่อนใช้

function greet(name: string, greeting: string = "สวัสดี"): string {
  return greeting + " " + name;
}

console.log(greet("สมชาย"));
console.log(greet("สมชาย", "หวัดดี"));
console.log(greet("สมชาย", undefined));   // ส่ง undefined เข้าไปตรง ๆ เลย

tsc --noEmit --strict ผ่าน และ node พิมพ์ออกมา:

สวัสดี สมชาย
หวัดดี สมชาย
สวัสดี สมชาย

บรรทัดที่สามคือจุดที่คนสะดุดกันเยอะ — เราส่ง undefined เข้าไปด้วยมือ แท้ ๆ แต่ยังได้ "สวัสดี" อยู่ดี
เพราะกฎของค่าตั้งต้นคือ "ถ้าค่าที่ได้รับเป็น undefined ให้ใช้ค่าตั้งต้นแทน" ไม่ใช่ "ถ้าไม่ได้ส่งมา" — สองประโยคนี้ฟังคล้ายกันแต่ไม่เท่ากัน และเวลาคุณส่งค่าที่มาจากตัวแปรอีกที (เช่น greet(name, userPref)) ความต่างนี้จะโผล่มาทันที

และถ้าคุณลองใส่ทั้งสองอย่างพร้อมกัน TypeScript ตัดจบเลย:

function sum(num1: number, num2?: number = 0): number {
  return num1 + num2;
}
both.ts(1,28): error TS1015: Parameter cannot have question mark and initializer.

เลือกอย่างใดอย่างหนึ่ง — และเลือกไม่ยาก: ถ้ามีค่าที่สมเหตุสมผลให้ใช้แทนได้ ใช้ = (จะได้ไม่ต้องเช็คทีหลัง) · ถ้า "ไม่มีค่า" เป็นสถานะที่มีความหมายจริง ๆ ใช้ ? แล้วยอมเช็ค

🧰 เทียบให้เห็นภาพ — ใบสมัครที่มีช่อง "ชื่อเล่น"

· ? = ช่องที่เว้นว่างได้ — เจ้าหน้าที่ได้ใบมาแล้วเห็นช่องว่าง เขาต้องตัดสินใจเองทุกครั้งว่าจะทำยังไงกับช่องนั้น (ข้ามไป? โทรถาม? ปฏิเสธใบ?) — คือคุณต้องเขียน if
· = = ช่องที่พิมพ์ค่าไว้ให้แล้ว — ในช่องมีคำว่า "ไม่ระบุ" พิมพ์ไว้ล่วงหน้า ใครไม่แก้ก็ใช้คำนั้น เจ้าหน้าที่ไม่ต้องตัดสินใจอะไรเลย

ที่ไม่เหมือนกัน: ในโลกกระดาษ ถ้าคุณลบคำว่า "ไม่ระบุ" ทิ้งจนช่องว่างเปล่า เจ้าหน้าที่จะเห็นเป็นช่องว่าง
แต่ในโค้ด การส่ง undefined เข้าไปทับค่าตั้งต้น ไม่ได้ทำให้มันว่าง — ค่าตั้งต้นเด้งกลับมาเหมือนเดิม (ตามผลรันข้างบน) คุณ "ลบ" ค่าตั้งต้นด้วยวิธีนี้ไม่ได้

รับกี่ตัวก็ได้ — rest parameter ...

สไลด์หน้า 28 ก้อนล่างเป็นเรื่องนี้: จะทำยังไงถ้าไม่รู้ล่วงหน้าว่าจะมีของเข้ามากี่ชิ้น
คำตอบคือจุดสามจุดหน้าชื่อ parameter แล้วประกาศชนิดเป็นอาเรย์

ผมพิมพ์ตามสไลด์เป๊ะอีกครั้ง แล้วสั่ง tsc --noEmit --strict:

// slide28b.ts — โค้ดจากสไลด์หน้า 28 ก้อนล่าง
const sum = (...num: number[]): number => {
  return numbers.reduce((total, n) => total + n, 0);
}
slide28b.ts(3,10): error TS2552: Cannot find name 'numbers'. Did you mean 'Number'?
slide28b.ts(3,26): error TS7006: Parameter 'total' implicitly has an 'any' type.
slide28b.ts(3,33): error TS7006: Parameter 'n' implicitly has an 'any' type.

สาม error จากโค้ดสามบรรทัด — แต่ต้นเหตุมีอันเดียว: ประกาศชื่อ parameter ว่า num แต่ในตัวฟังก์ชันเรียกว่า numbers
อีกสอง error ที่เหลือเป็นผลพวง: พอ TypeScript ไม่รู้จัก numbers มันก็ไม่รู้ว่า reduce ของอะไร เลยเดา type ของ total กับ n ไม่ออกตามไปด้วย

⚠️ นิสัยที่ควรติดตัวตั้งแต่วันนี้

เวลา tsc ด่ามาเป็นพวง อย่าไล่แก้จากล่างขึ้นบน และอย่าแก้ทีละอัน
ให้แก้ อันบนสุดอันเดียว แล้วรันใหม่ — บ่อยครั้งอีกสิบอันหายไปเอง
เคสนี้คือตัวอย่างเป๊ะ ๆ: แก้ชื่อตัวแปรจุดเดียว error หายทั้งสามอัน

แก้ให้ชื่อตรงกัน (ผมตั้งเป็น nums) แล้วใส่ console.log ดูของจริงข้างใน:

const sum = (...nums: number[]): number => {
  console.log("ข้างในฟังก์ชัน nums =", nums);
  return nums.reduce((total, n) => total + n, 0);
};

console.log(sum(1, 2, 3));
console.log(sum());

tsc --noEmit --strict เงียบ และ node พิมพ์:

ข้างในฟังก์ชัน nums = [ 1, 2, 3 ]
6
ข้างในฟังก์ชัน nums = []
0

สังเกตสองอย่างที่ผลรันบอกเรา:
1. ตอนเรียกคุณส่งของแยกชิ้น (1, 2, 3) แต่ข้างในฟังก์ชันมันกลายเป็น อาเรย์ก้อนเดียว ([ 1, 2, 3 ]) — rest parameter คือคนเก็บกวาดตรงนี้ให้
2. เรียกโดยไม่ส่งอะไรเลย ได้ อาเรย์ว่าง [] ไม่ใช่ undefined
ข้อ 2 สำคัญกว่าที่คิด: rest parameter ไม่มีวันเป็น undefined คุณจึงเรียก .length หรือ .reduce() ต่อได้เลยโดยไม่ต้องเช็คก่อน ต่างจาก ? อย่างสิ้นเชิง

ลำดับของ parameter มีกฎอยู่

สามอย่างที่เพิ่งเรียนมาต่อกันมั่วไม่ได้ ผมลองเขียนผิดสองแบบให้ดู:

// order.ts
function greet(name?: string, age: number): string {
  return name + " " + age;
}

function tally(...nums: number[], label: string): number {
  return nums.length;
}
order.ts(2,31): error TS1016: A required parameter cannot follow an optional parameter.
order.ts(6,16): error TS1014: A rest parameter must be last in a parameter list.

เหตุผลของทั้งสองข้อคือเรื่องเดียวกัน: ตอนเรียกใช้ เราระบุ argument ด้วยตำแหน่ง ไม่ใช่ด้วยชื่อ
ถ้าช่องที่ข้ามได้อยู่ตรงกลาง แล้วมีคนเรียก greet("โบ") — TypeScript จะรู้ได้ยังไงว่า "โบ" คือ name หรือคือ age ที่เลื่อนมาแทน
ส่วน rest parameter กวาดของที่เหลือทั้งหมด ถ้ามีช่องอยู่หลังมัน ก็จะไม่เหลืออะไรให้ช่องนั้นตลอดกาล

เขียนแบบชนิดจริงข้างในฟังก์ชันต้องเช็คก่อนใช้ไหมวางตรงไหนได้
num2: number number ไม่ต้อง ก่อนพวก ? ทั้งหมด
num2?: number number | undefined ต้อง ไม่งั้น TS18048 หลังช่องบังคับ · ก่อน rest
num2: number = 0 number ไม่ต้อง ที่ไหนก็ได้ · ถ้าอยู่กลางต้องส่ง undefined มาข้าม
...nums: number[] number[] (ว่างได้ แต่ไม่เป็น undefined) ไม่ต้อง ช่องสุดท้ายเท่านั้น

ของแถมที่ tsc จับให้ฟรี ๆ ตอนเรียกใช้

พอบรรทัดบนสุดของฟังก์ชันมี type ครบ ฝั่งคนเรียกก็ถูกตรวจให้ด้วย:

// callsite.ts
function sum(num1: number, num2: number): number {
  return num1 + num2;
}

sum(2);
sum(2, 3, 4);
sum(2, "3");
callsite.ts(6,1): error TS2554: Expected 2 arguments, but got 1.
callsite.ts(7,11): error TS2554: Expected 2 arguments, but got 3.
callsite.ts(8,8): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

ทั้งสามอันนี้ใน JavaScript ธรรมดารันผ่านหมด ไม่มีใครทัก — sum(2) จะได้ NaN · sum(2, "3") จะได้สตริง "23"
ส่วนสองอันนี้คือกับดักของคนที่เพิ่งย้ายมาจาก JavaScript แล้วยังไม่ชิน:

// noparam.ts — ลืมใส่ type ให้ parameter
function greet(name) {
  return "สวัสดี " + name;
}
noparam.ts(2,16): error TS7006: Parameter 'name' implicitly has an 'any' type.
// voidret.ts — บอกว่าไม่คืนอะไร แต่ดัน return
function logIt(msg: string): void {
  console.log(msg);
  return msg.length;
}
voidret.ts(4,3): error TS2322: Type 'number' is not assignable to type 'void'.

void คือ return type ที่แปลว่า "ฟังก์ชันนี้ไม่คายอะไรออกมา" ใช้กับฟังก์ชันที่มีไว้ทำอะไรบางอย่าง (พิมพ์จอ · ยิง API · เปลี่ยนหน้าจอ) ไม่ได้มีไว้คำนวณค่า

🗣 ศัพท์ที่เพิ่งเรียนไป
arrow function
ฟังก์ชันทรงที่เขียนด้วย => แล้วเก็บไว้ใน const · คู่กันคือ function declaration ที่ขึ้นต้นด้วยคำว่า function · ถาม senior ได้ว่า “ที่นี่มีกฎว่าเมื่อไหร่ใช้ arrow เมื่อไหร่ใช้ function ไหม” เกือบทุกทีมมีคำตอบ และมันมักผูกกับเรื่อง this ที่อยู่หัวข้อถัดไป
void
return type ที่แปลว่าฟังก์ชันนี้ไม่คายค่าอะไรออกมา · ใส่ return ค่า เข้าไปจะได้ TS2322 · พิมพ์บอก AI ได้ว่า “this handler returns void — do the work, don't return a value”
implicit any
สภาพที่ TypeScript หาชนิดของอะไรสักอย่างไม่เจอ เลยปล่อยเป็น any ให้ (คือ "อะไรก็ได้" คือเลิกตรวจ) · ธง noImplicitAny ที่มากับ --strict ทำให้มันกลายเป็น error TS7006 แทน · ถาม senior ได้ว่า “โปรเจกต์นี้เปิด noImplicitAny อยู่ไหม” ถ้าคำตอบคือไม่ ให้รู้ตัวว่า type ที่เห็นในโค้ดอาจไม่ได้ถูกตรวจจริงทั้งหมด

ความต่างที่จับต้องได้ระหว่าง arrow function กับ function ปกติ: this

ตอนต้นบทผมบอกว่าสองทรงนี้ต่างกันแค่รูปประโยค — เกือบจริง มีอยู่จุดหนึ่งที่ต่างกันจริง ๆ และมันเป็นเหตุผลที่ arrow function ชนะใจคนเขียน frontend

this คือคำที่ใช้อ้างถึง "ก้อนที่ฟังก์ชันนี้สังกัดอยู่" ปัญหาคือใน function ปกติ ค่าของ this ถูกตัดสินตอนถูกเรียก ไม่ใช่ตอนเขียน ส่วน arrow function ไม่มี this ของตัวเอง มันหยิบของที่ล้อมมันอยู่มาใช้

ผมเขียนของก้อนเดียวกัน วนลูปแบบเดียวกัน ต่างกันแค่ทรงของฟังก์ชันข้างใน:

const cart = {
  label: "ตะกร้า",
  items: ["ไข่", "นม"],

  withArrow(): void {
    this.items.forEach((item) => {
      console.log("arrow  → this.label =", this.label, "| item =", item);
    });
  },

  withNormal(): void {
    this.items.forEach(function (this: any, item: string) {
      console.log("normal → this =", this, "| item =", item);
    });
  },
};

cart.withArrow();
cart.withNormal();

คอมไพล์แล้วรันด้วย node จริง ได้:

arrow  → this.label = ตะกร้า | item = ไข่
arrow  → this.label = ตะกร้า | item = นม
normal → this = undefined | item = ไข่
normal → this = undefined | item = นม

บรรทัดสามกับสี่คือของจริง — this ข้างใน function ปกติ ที่ถูกส่งให้ forEach ไปเรียก กลายเป็น undefined ทั้งที่มันเขียนอยู่ในตัว cart แท้ ๆ ห่างกันสองบรรทัด
ส่วน arrow function เห็น this.label เป็น "ตะกร้า" ตามที่ควรจะเป็น

ทำไมถึงได้ undefined ไม่ใช่ค่าอื่น — เพราะไฟล์ .js ที่ tsc คอมไพล์ออกมามีคำว่า "use strict" อยู่บรรทัดแรก ผมเปิดดูแล้วและลองทั้งสองทาง: มี "use strict" ได้ undefined · ไม่มี ได้ globalThis (ก้อนใหญ่ ๆ เต็มจอ)
แปลว่าถ้าคุณเอาโค้ดนี้ไปแปะที่อื่นที่ไม่ได้อยู่ในโหมด strict (เช่น console ของเบราว์เซอร์) แล้วได้ผลไม่เหมือนที่โชว์ไว้ ไม่ใช่คุณทำผิด — ให้รันผ่านไฟล์ตามคำสั่งในบทถึงจะได้ผลเดียวกัน

และถ้าคุณไม่ได้เขียน this: any กำกับไว้อย่างในตัวอย่างข้างบน TypeScript แบบ --strict จะไม่ยอมให้คอมไพล์ตั้งแต่แรก:

// this-bad.ts
const cart = {
  label: "ตะกร้า",
  items: ["ไข่", "นม"],

  withNormal(): void {
    this.items.forEach(function (item: string) {
      console.log(this.label, item);
    });
  },
};
this-bad.ts(8,19): error TS2683: 'this' implicitly has type 'any' because it does not have a type annotation.
🧰 เทียบให้เห็นภาพ — พนักงานที่ถูกยืมตัวไปช่วยงานแผนกอื่น

· function ปกติ = พนักงานที่ต้องดูป้ายที่แขวนอยู่ในห้องที่ตัวเองยืนอยู่ตอนนั้น
ถูกยืมตัวไปช่วยแผนกไหน ก็ตอบว่า "ผมสังกัดแผนกนั้น" ตามป้ายในห้อง
พอถูกยืมไปยืนในห้องที่ไม่มีป้ายแขวนเลย เขาก็ตอบไม่ได้ — ได้ undefined

· arrow function = พนักงานที่ห้อยบัตรพนักงานของตัวเองไว้ที่คอ
ถูกยืมตัวไปยืนที่ไหนก็ตาม ก้มดูบัตรตัวเองแล้วตอบได้เหมือนเดิมทุกครั้ง
บัตรใบนั้นถูกออกให้ตอนที่เขาถูกเขียนขึ้นมา ไม่ใช่ตอนถูกเรียกใช้

ที่ไม่เหมือนกัน: พนักงานที่ห้อยบัตรตัวเอง ยังถอดบัตรเปลี่ยนได้ถ้าจำเป็น
แต่ arrow function เปลี่ยน this ไม่ได้เลย — ต่อให้ใช้ call หรือ bind ยัดใส่ ก็ไม่มีผล
ผลที่ตามมาคือ arrow function จึงไม่เหมาะจะใช้เป็น method ของ object ที่ต้องอ้างถึงตัวเอง (ให้ใช้ทรง withArrow(): void { … } อย่างในโค้ดข้างบนแทน)
สรุปกฎที่ใช้ได้จริง: ฟังก์ชันที่ส่งไปให้คนอื่นเรียก (callback) ให้ใช้ arrow · method ของ object ให้ใช้ทรงปกติ

🗣 ศัพท์ที่เพิ่งเรียนไป
optional parameter (?)
ช่องที่ไม่ใส่ก็ได้ · ชนิดจริงกลายเป็น ชนิดเดิม | undefined · ถาม senior ได้ว่า “ตรงนี้ควรใช้ optional หรือควรมี default” พร้อมเหตุผลว่า “เพราะ ‘ไม่มีค่า’ มีความหมายต่างจาก ‘ค่าศูนย์’ ไหม”
default parameter (= ค่า)
ช่องที่มีค่าตั้งต้น ทำงานเมื่อค่าที่ได้รับเป็น undefined · พิมพ์บอก AI ได้ว่า “use a default parameter instead of an optional one so callers don't need a null check”
rest parameter (...)
ช่องสุดท้ายที่กวาด argument ที่เหลือทั้งหมดมาเป็นอาเรย์ · ว่างได้ แต่ไม่เป็น undefined · ถาม senior ได้ว่า “ฟังก์ชันนี้ควรรับ rest parameter หรือรับอาเรย์ไปเลยหนึ่งตัว” คนละเรื่องกัน: sum(1,2,3) กับ sum([1,2,3])
narrowing
การที่ TypeScript ตัดชนิดที่เป็นไปไม่ได้ทิ้ง หลังคุณเขียน if เช็คไว้ · พิมพ์บอก AI ได้ว่า “narrow this value instead of casting it with as” เป็นวิธีสั่งที่ตรงกว่า “อย่าใช้ any”
🛠️ ภารกิจ — ทำให้ tsc ด่าคุณให้ครบทั้ง 6 แบบ แล้วเขียนไฟล์พิสูจน์

เป้าหมายไม่ใช่ "ก๊อปโค้ดในบทนี้ไปวางแล้วรันผ่าน" — เป้าหมายคือ สั่งให้มันพังในแบบที่คุณเลือกได้ แล้วอ่าน error ออกว่าเกิดจากอะไร

  1. ตั้งโครง: สร้างโฟลเดอร์ใหม่ · ตรวจว่ามี tsc ใช้ได้ด้วย tsc -v
    ทุกข้อต่อจากนี้สั่งด้วย tsc --noEmit --strict ไฟล์.ts เสมอ
    อย่าลืม --strict — ถ้าไม่ใส่ ข้อ 2 กับข้อ 5 จะไม่ด่า แล้วคุณจะสรุปผิด
  2. เก็บ error ให้ครบ 6 เลข: เขียนไฟล์เล็ก ๆ ไฟล์ละอัน ให้ได้เลขเหล่านี้ครบ แล้วจดข้อความเต็ม ๆ ที่ได้ไว้
    TS18048 · TS7006 · TS2554 · TS2345 · TS1016 · TS1015
    (ทั้งหมดมีตัวอย่างอยู่ในบทนี้แล้ว แต่ให้เขียนโจทย์ใหม่ของคุณเอง ไม่ใช่ก๊อป)
  3. พิสูจน์เรื่อง return type ด้วยตัวเอง: เขียนฟังก์ชันที่คืนค่าผิดชนิด แล้วให้มีคนเรียกใช้ 3 ที่
    รันสองรอบ: รอบแรกไม่เขียน return type · รอบสองเขียน
    ต้องได้จำนวน error ต่างกัน — จดว่าได้กี่อันต่อกี่อัน และแต่ละอันชี้ไปที่บรรทัดไหน
  4. ทำให้ NaN เกิดกับตัวเอง: เขียนฟังก์ชันที่มี optional parameter แล้วเอาไปบวกโดยไม่เช็ค · ยัด as number ให้มันคอมไพล์ผ่าน แล้วคอมไพล์เป็น .js และรันด้วย node จริง
    ต้องเห็นคำว่า NaN ออกมาบนจอด้วยตาตัวเอง แล้วค่อยแก้ด้วย if ให้ได้ค่าที่ถูก
  5. เขียนไฟล์พิสูจน์ (นี่คือข้อที่ห้ามข้าม): สร้าง check.ts ที่มี ฟังก์ชันของคุณเอง 1 ตัว ซึ่งมีครบทั้ง parameter บังคับ · default · และ rest
    ข้างล่างเขียนชุดตรวจแบบนี้ แล้วรันด้วย node:
    if (myFn(...) !== ค่าที่คาด) { throw new Error("เคส A พัง"); }
    อย่างน้อย 5 เคส รวมเคสที่ไม่ส่ง argument เลย ด้วย
    เกณฑ์ผ่าน: รันแล้วเงียบสนิท และ echo $? ได้ 0 — ถ้าได้ 1 แปลว่าค่าที่คุณคาดไว้ผิด ไม่ใช่โปรแกรมผิด
  6. ทดลองเรื่อง this: ทำ object ที่มีอาเรย์ข้างใน แล้ววน forEach สองรอบ รอบหนึ่งใช้ arrow รอบหนึ่งใช้ function ปกติ
    ต้องเห็น undefined โผล่จากฝั่ง function ปกติ
    ถ้าไม่เห็น undefined แต่ได้ก้อนใหญ่ ๆ ของ globalThis ออกมาแทน ให้เปิดไฟล์ .js ที่คอมไพล์ออกมาดูว่าบรรทัดแรกมีคำว่า "use strict" ไหม — ผมรันแล้วยืนยันว่าตัวที่ตัดสินคือบรรทัดนี้: มี "use strict" ได้ undefined · ไม่มี ได้ globalThis (tsc ใส่ให้เองอยู่แล้วตามคำสั่งในข้อ 4)

โครงที่ผมใช้ตรวจในบทนี้เป็นแบบนี้ ปรับชื่อกับค่าเป็นของคุณเองได้เลย:

function bill(base: number, vat: number = 0, ...extras: number[]): number {
  const more = extras.reduce((t, n) => t + n, 0);
  return base + vat + more;
}

const cases: [number, string][] = [
  [bill(100), "A ไม่ส่ง vat ไม่ส่ง extras"],
  [bill(100, 7), "B ส่ง vat"],
];
const want = [100, 107];

cases.forEach(([got, name], i) => {
  if (got !== want[i]) {
    throw new Error(name + " พัง: ได้ " + got + " ควรได้ " + want[i]);
  }
});

คำสั่งที่ใช้ทั้งสองขั้น:
tsc --noEmit --strict check.ts
tsc --target es2022 --module nodenext --moduleResolution nodenext check.ts && node check.js
ให้ลองทำให้มันพังก่อนหนึ่งครั้ง (แก้ want ให้ผิดจงใจ) จะได้รู้ว่าตอนพังหน้าตาเป็นยังไง — ชุดตรวจที่ไม่เคยเห็นตอนพัง เชื่อไม่ได้ว่ามันตรวจอยู่จริง

ทดสอบความเข้าใจ

1. โค้ดจากสไลด์หน้า 28 ก้อนบน: (num1: number = 0, num2?: number): number => { return num1 + num2 } — สั่ง tsc --noEmit --strict แล้วได้อะไร?

ผลรันจริงคือ slide28a.ts(3,17): error TS18048: 'num2' is possibly 'undefined'. (บรรทัด 3 คือบรรทัด return เมื่อนับรวมคอมเมนต์บนสุด)
เพราะ num2?: number ทำให้ชนิดจริงเป็น number | undefined เอาไปบวกเลยไม่ได้
ตัวเลือกที่ 1 คือกับดักตัวจริง เพราะเราเห็นคำว่า number เขียนอยู่ตรงนั้นกับตา — แต่? เปลี่ยนชนิดไปแล้ว ไม่ใช่แค่ทำให้ "ไม่ต้องส่งก็ได้"
ตัวเลือกที่ 3 ผิดที่ตำแหน่ง — TS2554 มีจริงและด่าตอนเรียกใช้จริง แต่มันเกิดกับ parameter บังคับที่ไม่ได้ส่งมา ซึ่งตรงข้ามกับเคสนี้พอดี
ตัวเลือกที่ 4 ผิดเพราะ TS1015 เกิดตอนใส่ ? กับ = 0 ที่ช่องเดียวกัน · ในโค้ดนี้ = 0 อยู่ที่ num1 ส่วน ? อยู่ที่ num2 คนละช่องกัน
จุดที่ต้องจำ: เห็น ? ท้ายชื่อ parameter เมื่อไหร่ ให้อ่านชนิดของมันว่า "ชนิดนั้น หรือ undefined" ทันที แล้วมองหาว่า if ที่เช็คอยู่ตรงไหน

2. ฟังก์ชันหนึ่งเขียน return ผิดชนิด และมีคนเรียกใช้อยู่หลายที่ — ถ้าไม่เขียน return type กำกับไว้ error จะโผล่ที่ไหน?

ผมรันไฟล์คู่ที่ต่างกันแค่ : number ตัวเดียว:
infer-a.ts(6,7): error TS2322: Type 'string' is not assignable to type 'number'. (ไม่เขียนกำกับ · บรรทัด 6 คือที่เรียกใช้)
infer-b.ts(3,3): error TS2322: Type 'string' is not assignable to type 'number'. (เขียนกำกับ · บรรทัด 3 คือ return)
ตัวเลือกที่ 3 คือความเข้าใจผิดที่พบบ่อยที่สุด — การเดาได้ไม่ได้แปลว่ายอมรับว่าถูก มันเดาว่าเป็น string แล้วไปชนกับที่ที่ต้องการ number
ตัวเลือกที่ 1 ผิดตรงคำว่า "ทุกประการ" ซึ่งเป็นประเด็นทั้งหมดของเรื่องนี้พอดี
ตัวเลือกที่ 4 ผิดเพราะได้อันเดียวเสมอ ไม่ใช่สองอัน — และมันได้คนละที่กันด้วย
จุดที่ต้องจำ: เขียน return type ไม่ได้ทำให้ TypeScript ฉลาดขึ้น แต่มันย้าย error ไปวางไว้ตรงที่คนพิมพ์ผิด แทนที่จะกระจายไปทั่วบ้านคนอื่น

3. ประกาศ function greet(name: string, greeting: string = "สวัสดี") แล้วเรียกด้วย greet("สมชาย", undefined) — ได้อะไร?

ผมรันจริงทั้งสามบรรทัดต่อกัน ได้ สวัสดี สมชาย / หวัดดี สมชาย / สวัสดี สมชาย
บรรทัดที่สามคือเคสนี้ — ค่าตั้งต้นเด้งกลับมา
ตัวเลือกที่ 2 คือคำตอบที่ฟังมีเหตุผลที่สุดและผิด: กฎของค่าตั้งต้นไม่ใช่ "ถ้าไม่ได้ส่งมา" แต่คือ "ถ้าค่าที่ได้รับเป็น undefined" — การส่งมาเองก็เข้าเงื่อนไขนั้น
ตัวเลือกที่ 1 ผิดเพราะ TypeScript อนุญาตให้ส่ง undefined เข้าช่องที่มีค่าตั้งต้นได้โดยตั้งใจ เพื่อให้คุณ "ข้าม" ช่องกลางไปใส่ช่องขวาได้
ตัวเลือกที่ 4 ผิดเพราะไม่มีอะไรพัง — ถ้าจะพังจริง ต้องเป็นเคส ? ที่เอาไปบวกเลข ซึ่งได้ NaN เงียบ ๆ ไม่ใช่พัง
จุดที่ต้องจำ: เวลาส่งค่าที่มาจากตัวแปรอีกทีเข้าช่องที่มีค่าตั้งต้น (เช่น greet(name, userPref)) ให้รู้ตัวว่าถ้า userPref เป็น undefined ค่าตั้งต้นจะทำงาน — ซึ่งบางทีก็คือสิ่งที่ต้องการ บางทีก็คือ bug

4. โค้ดจากสไลด์หน้า 28 ก้อนล่าง ประกาศ (...num: number[]) แต่ในตัวฟังก์ชันเขียน numbers.reduce(...) — tsc --noEmit --strict ว่าไง?

ผลรันจริงมีสามบรรทัด เรียงตามนี้:
slide28b.ts(3,10): error TS2552: Cannot find name 'numbers'. Did you mean 'Number'?
slide28b.ts(3,26): error TS7006: Parameter 'total' implicitly has an 'any' type.
slide28b.ts(3,33): error TS7006: Parameter 'n' implicitly has an 'any' type.
ตัวเลือกที่ 4 คือกับดักที่เนียนที่สุด เพราะ TS7006 โผล่มาจริงและมีสองอันจริง — ส่วนที่ผิดคือคำว่า "อย่างเดียว" กับ "ปล่อยผ่านให้" · TS2552 คือหัวขบวนและเป็นต้นเหตุของอีกสองอัน
ตัวเลือกที่ 2 ผิดเพราะ TypeScript ไม่เดาชื่อตัวแปรให้ใคร มันบอกได้แค่ว่า "หมายถึง Number รึเปล่า"
ตัวเลือกที่ 3 ผิดเพราะโค้ดส่ง 0 เป็นค่าตั้งต้นให้ reduce อยู่แล้ว
จุดที่ต้องจำ: error เป็นพวงมักมีต้นตอเดียว — แก้อันบนสุดแล้วรันใหม่ ก่อนจะไปแตะอันอื่น ในเคสนี้แก้ชื่อจุดเดียว หายทั้งสามอัน

💬 ถามกลับได้ — ถ้ามีก้อนไหนในบทนี้รันแล้วได้ไม่เหมือนที่ผมเขียน ให้ส่งสามอย่างนี้มา: โค้ดที่รัน · คำสั่งเต็ม ๆ ที่พิมพ์ · และ ผลที่ได้แบบก็อปมาทั้งบรรทัด
เรื่องที่ต่างกันได้จริงมีสามอย่าง:
1. ลืม --strict — ข้อ 1 กับข้อ 4 จะเงียบไปเลย
2. เลขบรรทัดในวงเล็บไม่ตรง แต่ TSxxxx กับข้อความตรง — อันนี้ไม่ใช่ปัญหา แปลว่าไฟล์คุณมีบรรทัดไม่เท่ากับของผม ทุกก้อนในบทนี้ผมนับรวมบรรทัดคอมเมนต์ // ชื่อไฟล์.ts ที่อยู่บนสุดด้วย ถ้าคุณไม่ได้ก็อปคอมเมนต์ไปด้วย เลขจะน้อยกว่าที่โชว์อยู่หนึ่ง
3. เวอร์ชัน tsc ไม่ตรงกัน — ข้อความบางอันของ TypeScript 5 กับ 7 ไม่เหมือนกัน ผมตรวจบทนี้บน 7.0.2

อ้างอิง