🧰 ของที่ใช้ตรวจในบทนี้
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ทำให้มันกลายเป็น errorTS7006แทน · ถาม 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”
เป้าหมายไม่ใช่ "ก๊อปโค้ดในบทนี้ไปวางแล้วรันผ่าน" — เป้าหมายคือ สั่งให้มันพังในแบบที่คุณเลือกได้ แล้วอ่าน error ออกว่าเกิดจากอะไร
- ตั้งโครง: สร้างโฟลเดอร์ใหม่ · ตรวจว่ามี
tscใช้ได้ด้วยtsc -v
ทุกข้อต่อจากนี้สั่งด้วยtsc --noEmit --strict ไฟล์.tsเสมอ
อย่าลืม--strict— ถ้าไม่ใส่ ข้อ 2 กับข้อ 5 จะไม่ด่า แล้วคุณจะสรุปผิด - เก็บ error ให้ครบ 6 เลข: เขียนไฟล์เล็ก ๆ ไฟล์ละอัน ให้ได้เลขเหล่านี้ครบ
แล้วจดข้อความเต็ม ๆ ที่ได้ไว้
TS18048·TS7006·TS2554·TS2345·TS1016·TS1015
(ทั้งหมดมีตัวอย่างอยู่ในบทนี้แล้ว แต่ให้เขียนโจทย์ใหม่ของคุณเอง ไม่ใช่ก๊อป) - พิสูจน์เรื่อง return type ด้วยตัวเอง: เขียนฟังก์ชันที่คืนค่าผิดชนิด
แล้วให้มีคนเรียกใช้ 3 ที่
รันสองรอบ: รอบแรกไม่เขียน return type · รอบสองเขียน
ต้องได้จำนวน error ต่างกัน — จดว่าได้กี่อันต่อกี่อัน และแต่ละอันชี้ไปที่บรรทัดไหน - ทำให้
NaNเกิดกับตัวเอง: เขียนฟังก์ชันที่มี optional parameter แล้วเอาไปบวกโดยไม่เช็ค · ยัดas numberให้มันคอมไพล์ผ่าน แล้วคอมไพล์เป็น.jsและรันด้วยnodeจริง
ต้องเห็นคำว่าNaNออกมาบนจอด้วยตาตัวเอง แล้วค่อยแก้ด้วยifให้ได้ค่าที่ถูก - เขียนไฟล์พิสูจน์ (นี่คือข้อที่ห้ามข้าม): สร้าง
check.tsที่มี ฟังก์ชันของคุณเอง 1 ตัว ซึ่งมีครบทั้ง parameter บังคับ · default · และ rest
ข้างล่างเขียนชุดตรวจแบบนี้ แล้วรันด้วยnode:if (myFn(...) !== ค่าที่คาด) { throw new Error("เคส A พัง"); }
อย่างน้อย 5 เคส รวมเคสที่ไม่ส่ง argument เลย ด้วย
เกณฑ์ผ่าน: รันแล้วเงียบสนิท และecho $?ได้0— ถ้าได้1แปลว่าค่าที่คุณคาดไว้ผิด ไม่ใช่โปรแกรมผิด - ทดลองเรื่อง
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
อ้างอิง
- TypeScript Handbook — More on Functions —
อ่านสองหัวข้อพอ: Optional Parameters และ Rest Parameters
หัวข้อแรกมีประโยคสำคัญที่บทนี้พิสูจน์ให้ดูแล้วว่า
?ทำให้ชนิดกลายเป็น union กับundefined· ที่เหลือในหน้านั้น (overload, generic) ยังไม่ต้องอ่านตอนนี้ - TypeScript Handbook — Everyday Types §Functions — สั้นมาก อ่านให้จบทั้งหัวข้อ เป็นที่ที่อธิบายว่าทำไม return type ถึงไม่ต้องเขียนก็ได้ อ่านคู่กับหัวข้อ "TypeScript เดา return type ให้เองได้" ในบทนี้ แล้วคุณจะเห็นว่าเอกสารไม่ได้บอก เรื่องตำแหน่งที่ error ไปโผล่ — ซึ่งเป็นเหตุผลจริงที่ทีมส่วนใหญ่บังคับให้เขียน
- MDN — Arrow function expressions —
อ่านหัวข้อ Cannot be used as methods ซึ่งเป็นอีกด้านของเรื่อง
thisที่บทนี้รันให้ดู · MDN ยกตัวอย่างตรงข้ามกับของผม (ผมโชว์ว่า arrow ชนะตอนเป็น callback · MDN โชว์ว่า arrow แพ้ตอนเป็น method) อ่านทั้งสองแล้วจะได้กฎครบทั้งสองทิศ - tsconfig —
strict— ดูรายการธงย่อยที่strictเปิดให้ทั้งหมด ตัวที่เกี่ยวกับบทนี้โดยตรงคือstrictNullChecks(ต้นทางของTS18048) ·noImplicitAny(TS7006) ·noImplicitThis(TS2683) — รู้ว่าเลขไหนมาจากธงไหน จะเดาได้ทันทีว่าโปรเจกต์ที่เงียบผิดปกติปิดอะไรไว้ - MDN —
NaN— อ่านย่อหน้าแรกกับหัวข้อ Testing against NaN จะเจอกับดักที่ต่อจากบทนี้พอดี:NaN === NaNเป็นfalseคุณจึงตรวจจับมันด้วย===ไม่ได้ ต้องใช้Number.isNaN()