🧰 ของที่ใช้ตรวจในบทนี้
Version 7.0.2 # tsc -v
v24.14.0 # node --version
go version go1.26.5 darwin/arm64 # ใช้เทียบกับ slice ของ Go
ทุกไฟล์ตรวจ type ด้วย tsc --noEmit --strict
แล้วคอมไพล์ด้วย tsc --target es2022 --module nodenext --moduleResolution nodenext
ก่อนรันด้วย node — ข้อความ error ทุกอันก็อปมาจาก tsc ตัวจริง ทั้งเลข TSxxxx และตัวหนังสือ
1 · ปัญหาที่สามคำสั่งนี้เกิดมาแก้
ข้อมูลในโปรแกรมเว็บมีสองรูปร่างที่เจอบ่อยที่สุด:
- string — ข้อความยาวก้อนเดียว เช่น
"Alice, Bob"เป็นรูปร่างที่เอาไปแสดงบนหน้าจอได้ทันที และเป็นรูปร่างที่มาจากช่องกรอกของผู้ใช้ - array — รายการของหลายชิ้น เช่น
["Alice", "Bob"]เป็นรูปร่างที่เอาไปวนลูป นับ กรอง เรียงได้
ปัญหาคือของที่คุณได้มา มักอยู่ในรูปร่างที่ผิดกับสิ่งที่คุณจะทำต่อ
ผู้ใช้พิมพ์ "Alice, Bob, Carol" มาให้ในช่องเดียว แต่คุณอยากนับว่ากี่คน — ต้องแปลงเป็น array ก่อน
คุณมี array ของชื่อ แต่จะเอาไปโชว์ใต้ชื่อสูตรอาหาร — ต้องแปลงกลับเป็น string ก่อน
| คำสั่ง | รับอะไรมา | คืนอะไรกลับ | พูดสั้น ๆ |
|---|---|---|---|
join | array | string | ร้อยหลายชิ้นให้เป็นก้อนเดียว |
split | string | array | ผ่าก้อนเดียวให้เป็นหลายชิ้น |
slice | string หรือ array | ชนิดเดิม | ตัดเอาบางส่วน ของเดิมไม่แตะ |
สังเกตช่องขวาสุดของ slice: "ของเดิมไม่แตะ" — จำประโยคนี้ไว้ก่อน
เดี๋ยวหัวข้อ 6 จะเป็นเรื่องนี้ทั้งหัวข้อ
2 · join — เอา array มาร้อยเป็นข้อความเดียว
สไลด์หน้า 30 เขียนไว้แบบนี้:
const teamA: string[] = ["Alice", "Bob"];
// ✅ Expect team A are "Alice, Bob"
const teamAMember = teamA.join(", ");
ตัวหนังสือที่ใส่ในวงเล็บ (ในที่นี้คือ ", " — คอมมาแล้วเคาะหนึ่งที)
คือสิ่งที่จะไปแทรกอยู่ระหว่างชิ้น ไม่ใช่ต่อท้ายทุกชิ้น
ของ 2 ชิ้น จะมีที่แทรกแค่ 1 จุด · ของ 4 ชิ้น จะมีที่แทรก 3 จุด
ผมรันจริงพร้อมเคสข้างเคียงทั้งหมด ได้แบบนี้:
const teamA: string[] = ["Alice", "Bob"];
console.log("1)", teamA.join(", "));
console.log("2)", typeof teamA.join(", "));
console.log("3)", teamA.join());
console.log("4)", teamA.join(""));
const messy = ["a", null, undefined, "b"];
console.log("5)", messy.join("-"));
console.log("6)", ([] as string[]).join(", "));
console.log("7)", ["Alice"].join(", "));
console.log("8)", [2026, 8, 27].join("-"));
console.log("9)", [["a", "b"], ["c"]].join("|"));
$ node a-join.js
1) Alice, Bob
2) string
3) Alice,Bob
4) AliceBob
5) a---b
6)
7) Alice
8) 2026-8-27
9) a,b|c
อ่านผลทีละบรรทัดแล้วจะได้กฎครบชุด:
- บรรทัด 2 — คืนมาเป็น
stringเสมอ ไม่ว่าข้างในจะเป็นอะไร - บรรทัด 3 —
join()ไม่ใส่อะไรเลย ไม่ได้แปลว่าไม่มีตัวคั่น แต่มันใช้คอมมาให้อัตโนมัติ ได้Alice,Bob(ไม่มีเคาะ) ถ้าอยากได้แบบไม่มีอะไรคั่นจริง ๆ ต้องเขียนjoin("")อย่างบรรทัด 4 - บรรทัด 5 —
nullกับundefinedกลายเป็นข้อความว่าง["a", null, undefined, "b"]จึงได้a---bที่มีขีดสามอัน ไม่ใช่a-b
ถ้าเห็นขีดติดกันเยอะผิดปกติบนหน้าเว็บ ให้สงสัยข้อนี้ก่อนเลย - บรรทัด 6 — array ว่างได้ข้อความว่าง (บรรทัดนั้นจึงว่างเปล่า) ไม่ใช่ error
- บรรทัด 8 — array ของตัวเลขก็ join ได้ มันแปลงเป็นตัวหนังสือให้เอง
join คือการร้อยพวงมาลัย — คุณมีดอกมะลิกองหนึ่ง (array)
เอาด้ายร้อยเข้าไประหว่างดอก ได้ออกมาเป็นพวงเดียว (string)
ด้ายคือตัวที่ใส่ในวงเล็บ — จะร้อยด้วยด้ายสีอะไรก็ได้ ด้วยคอมมา ด้วยขีด ด้วยลูกศร
split คือการตัดด้ายออก — พวงมาลัยกลับกลายเป็นดอกมะลิกองเดิม
ที่ไม่เหมือนกัน: ร้อยพวงมาลัยแล้วแกะออก คุณได้ดอกมะลิครบเท่าเดิมเสมอ
แต่ join แล้ว split กลับ ไม่การันตีว่าจะได้ของเดิม —
ถ้าในชื่อคนมีคอมมาอยู่แล้ว เช่น ["Alice, Jr.", "Bob"]
พอ join ด้วย ", " แล้ว split กลับด้วย ", " คุณจะได้ 3 ชิ้น ไม่ใช่ 2
นี่คือเหตุผลที่คนไม่เก็บข้อมูลจริงจังไว้ในรูป string ที่คั่นด้วยคอมมา
- serializer
- ตัวที่แปลงข้อมูลเป็นข้อความก้อนเดียวเพื่อเอาไปเก็บหรือส่งต่อ
สไลด์หน้า 30 เขียนไว้ตรง ๆ ว่า "It's more of a serializer than a transform" — แปลว่าjoinไม่ได้เปลี่ยนเนื้อข้อมูล มันแค่เปลี่ยนรูปร่างของภาชนะ
พิมพ์ถาม AI ได้ว่า: "ผมใช้join(",")เก็บ tag ลง database ช่องเดียว มันเป็น serializer ที่แย่ตรงไหน ควรใช้อะไรแทน" - delimiter (ตัวคั่น)
- ตัวหนังสือที่ใช้แบ่งชิ้นออกจากกัน — ในที่นี้คือ
", "
ศัพท์ตัวเดียวกันนี้ใช้ทั้งฝั่งjoinและsplitและใช้ทั้งใน CSV, log, และ config
ถาม senior ได้ว่า: "ไฟล์นี้ delimiter เป็นอะไร คอมมาหรือ tab" — เป็นคำถามที่ใช้ได้จริงในที่ทำงาน
3 · split — ผ่าข้อความให้เป็น array
สไลด์หน้า 31 ใช้ตัวอย่างนี้:
const helloWorld: string = "Hello World";
// ✅ Expect characters are
// ["H", "e", "l", "l", "o", " ", "W", "o", "r", "l", "d"]
const characters = helloWorld.split("");
ตัวที่ใส่ในวงเล็บคือ ตัวคั่นที่ให้ไปตามหาแล้วตัดตรงนั้น
ใส่ "" (ว่างเปล่า) = ตัดทุกช่องว่างระหว่างตัวอักษร = ได้ทีละตัวอักษร
const s: string = "Hello World";
console.log(s.split(" ").length);
console.log(s.split("").length);
console.log(s.split(",").length);
console.log(s.split(" ").join("-"));
สี่บรรทัดนี้พิมพ์อะไรออกมา? บรรทัดที่สาม (ตัวคั่นเป็นคอมมา ซึ่ง "Hello World" ไม่มีเลย) น่าคิดที่สุด
$ node p1.js
2
11
1
Hello-World
2 — split(" ") ตัดตรงเคาะ ได้ ["Hello", "World"]
11 — split("") ได้ทีละตัวอักษร รวมเคาะตรงกลางด้วย จึงเป็น 11 ไม่ใช่ 10
1 — นี่คือข้อที่คนทายผิดบ่อยที่สุด
หาคอมมาไม่เจอ มันไม่ได้คืน array ว่าง และไม่ error
แต่คืน ["Hello World"] คือ array ที่มีข้อความทั้งก้อนอยู่ข้างในชิ้นเดียว
4 — join ต่อกลับได้ แต่ด้วยด้ายเส้นใหม่ ได้ Hello-World
บรรทัดที่สามคือกับดักจริงในโปรแกรม: คุณเขียนโค้ดคาดว่าจะได้หลายชิ้น
แล้วไปหยิบ [1] มาใช้ ซึ่งเป็น undefined —
โปรแกรมไม่พังตรงนั้น แต่ไปพังอีกสิบบรรทัดถัดไป หาต้นตอยาก
นี่คือผลรันเคสอื่น ๆ ทั้งหมดที่ผมลอง:
$ node b-split.js
1) [
'H', 'e', 'l', 'l',
'o', ' ', 'W', 'o',
'r', 'l', 'd'
]
2) 11
3) [ 'Hello', 'World' ]
4) 2
5) [ 'Hello World' ] // split() ไม่ใส่อะไร (ต้องเลี่ยง type ก่อน — ดูข้างล่าง)
6) [ 'Hello World' ] // split(",") หาไม่เจอ
7) [ 'Alice', ' Bob', ' Carol' ] // "Alice, Bob, Carol".split(",")
8) [ 'Alice', 'Bob', 'Carol' ] // "Alice, Bob, Carol".split(", ")
9) [ '', 'a', 'b', '' ] // ",a,b,".split(",")
10) [ '' ] // "".split(",")
11) [] // "".split("")
12) [ 'a', 'b' ] // "a-b-c-d".split("-", 2)
13) [ 'ก', 'ิ', 'น' ]
14) 8
15) 5
สามอันที่ต้องหยุดดู:
- บรรทัด 7 กับ 8 — ต่างกันแค่เคาะเดียวใน delimiter
split(",")ทิ้งเคาะไว้ติดหน้าชื่อ ได้' Bob'ที่มีเคาะนำ
ชื่อที่มีเคาะนำจะทำให้การเทียบ=== "Bob"พังเงียบ ๆ — ทางแก้จริงไม่ใช่ไปเดา delimiter ให้เป๊ะ แต่ใช้.trim()ทีหลัง (หัวข้อ 8 มีให้ดู) - บรรทัด 9 — ตัวคั่นอยู่หัวและท้าย จะได้ชิ้นว่าง
''ติดมาด้วยหัวท้าย
ผู้ใช้เผลอพิมพ์คอมมาท้ายบรรทัด แล้วคุณนับได้ 4 ทั้งที่มีของจริง 2 — มาจากข้อนี้ - บรรทัด 10 กับ 11 — ข้อความว่างให้ผลคนละอย่างตาม delimiter
"".split(",")ได้[ '' ]— ยาว 1"".split("")ได้[]— ยาว 0
ถ้าคุณเช็กif (arr.length === 0)เพื่อดูว่า "ผู้ใช้ไม่ได้กรอกอะไรมา" มันจะไม่จับกรณีแรก
⚠️ กับดักคนไทย: split("") ไม่ได้แบ่งเป็น "ตัวอักษรที่คนอ่านเห็น"
บรรทัด 13–15 ข้างบนคือของจริงที่รันมา:
13) [ 'ก', 'ิ', 'น' ] // "กิน".split("") → คนเห็น 2 ตัว แต่ได้ 3
14) 8 // "👨👩👧".split("").length → emoji ตัวเดียว ได้ 8
15) 5 // [..."👨👩👧"].length → ยังไม่ใช่ 1 อยู่ดี
คำว่า "กิน" คนไทยอ่านเห็น 2 ตัว แต่ split("") ได้ 3
เพราะสระอิเป็นตัวอักษรแยกต่างหากในหน่วยความจำ
เพราะฉะนั้น ห้ามใช้ split("") เพื่อนับความยาวข้อความ หรือเพื่อกลับด้านข้อความ
ถ้าข้อความอาจมีภาษาไทยหรือ emoji — ผลจะเพี้ยนแบบที่เทสภาษาอังกฤษจับไม่ได้
ถ้าต้องการหั่นให้ตรงกับที่ตาเห็นจริง ๆ ต้องใช้ Intl.Segmenter ซึ่งอยู่นอกขอบเขตบทนี้
ข้อต่างเล็ก ๆ ที่ tsc จับได้: join() ว่างได้ แต่ split() ว่างไม่ได้
join เว้นวงเล็บว่างได้ (ได้คอมมาเป็นค่าเริ่มต้น) แต่ split ทำแบบเดียวกันไม่ได้
— และ TypeScript ไม่ปล่อยผ่าน:
const helloWorld: string = "Hello World";
// join() ไม่ใส่อะไรเลยได้
const joined: string = ["a", "b"].join();
// split() ไม่ใส่อะไรเลย — ไม่ได้
const parts = helloWorld.split();
$ tsc --noEmit --strict b2-split-noarg.ts
b2-split-noarg.ts(7,26): error TS2554: Expected 1-2 arguments, but got 0.
นี่คือของฟรีที่ TypeScript ให้ — ใน JavaScript ล้วน ๆ บรรทัดนี้รันผ่านและได้
[ 'Hello World' ] เงียบ ๆ ซึ่งแทบไม่เคยเป็นสิ่งที่ใครตั้งใจ
คำว่า 1-2 arguments ในข้อความ error กำลังบอกด้วยว่า split รับได้ถึงสองตัว —
ตัวที่สองคือ limit คือจำนวนชิ้นสูงสุดที่จะเอา (บรรทัด 12 ในผลรันข้างบน)
4 · slice — ตัดเอาบางส่วน (ใช้ได้ทั้ง string และ array)
สไลด์หน้า 32 เขียนไว้แบบนี้:
const errorMessages: string = "Hello World";
// ✅ Expect slice between are "llo "
const sliceBetween = errorMessages.slice(2, 6);
// ✅ Expect slice from are "llo World"
const sliceFrom = errorMessages.slice(2);
✅ ผมรันแล้ว สไลด์ถูกทั้งสองบรรทัด —
slice(2, 6) ได้ "llo " (มีเคาะติดท้ายจริง ๆ)
และ slice(2) ได้ "llo World"
📝 หมายเหตุเรื่องสไลด์: หน้า 32 พาดหัวว่า "Returns a new array with a portion of the original"
แต่โค้ดตัวอย่างเป็น string ไม่ใช่ array และตัวแปรชื่อ errorMessages
(ชื่อแบบพหูพจน์ที่ฟังเหมือน array) ทั้งที่ค่าข้างในคือ "Hello World"
ไม่ได้ผิดสาระ — slice ใช้ได้จริงทั้งสองชนิด แต่ถ้าอ่านสไลด์แล้วงงว่าตกลงมันของ array หรือของ string
คำตอบคือ ของทั้งคู่ และหัวข้อนี้จะรันให้ดูทั้งสองแบบ
กติกาการนับที่ต้องจำมีสองข้อ:
· เลขตัวแรกคือจุดเริ่ม นับจาก 0
· เลขตัวหลังคือจุดจบ แบบ "ไปไม่ถึง" — ตัดก่อนถึงตำแหน่งนั้น
ตำแหน่ง: 0 1 2 3 4 5 6 7 8 9 10
ตัวอักษร: H e l l o (เคาะ) W o r l d
↑ ↑
เริ่มที่ 2 จบก่อนถึง 6
slice(2, 6) → เอาตำแหน่ง 2, 3, 4, 5 → "llo " (4 ตัว = 6 - 2)
slice(2) → เอาตั้งแต่ 2 จนหมด → "llo World"
จำง่าย ๆ ว่า ความยาวของผลลัพธ์ = เลขหลัง − เลขหน้า
— 6 - 2 = 4 ตัวพอดี ไม่ต้องมานั่งเถียงกับตัวเองว่ารวมตัวที่ 6 ไหม
แต่สูตรนี้ใช้ได้เฉพาะตอนที่เลขทั้งคู่เป็นบวกและไม่เกินความยาวจริง —
ถ้าเลขหลังเกินขอบ มันหยุดที่ปลายให้เอง (รันแล้ว: "Hello World".slice(2, 100).length
ได้ 9 ไม่ใช่ 98) และถ้าเลขหน้ามากกว่าเลขหลังจะได้ 0 ไม่ใช่เลขติดลบ
เลขติดลบ — ของที่สไลด์ไม่ได้บอก แต่ใช้บ่อยกว่าเลขบวก
slice รับเลขติดลบได้ และแปลว่า "นับถอยหลังจากท้าย"
เจอบ่อยเวลาอยากได้ "3 รายการล่าสุด" โดยไม่ต้องรู้ว่าทั้งหมดมีกี่รายการ
const errorMessages: string = "Hello World";
console.log(JSON.stringify(errorMessages.slice(2, 6)));
console.log(JSON.stringify(errorMessages.slice(2)));
console.log(JSON.stringify(errorMessages.slice(-5)));
console.log(JSON.stringify(errorMessages.slice(-5, -1)));
console.log(JSON.stringify(errorMessages.slice(6, 2)));
$ node h-quote.js
"llo "
"llo World"
"World"
"Worl"
""
(ผมห่อด้วย JSON.stringify เพื่อให้เห็นเครื่องหมายคำพูด —
ไม่งั้นเคาะที่ติดท้าย "llo " จะมองไม่ออกเลยว่ามีอยู่)
slice(-5)= เอา 5 ตัวสุดท้าย →"World"slice(-5, -1)= 5 ตัวสุดท้าย แต่ตัดตัวสุดท้ายทิ้ง →"Worl"
สูตรที่ใช้จริงบ่อยมากคือslice(0, -1)= "ตัดตัวท้ายทิ้งหนึ่งตัว" เช่นเวลาต้องการลบคอมมาที่เกินมาท้ายข้อความslice(6, 2)= เลขหน้ามากกว่าเลขหลัง → ได้ข้อความว่าง ไม่ error ไม่กลับด้านให้
ข้อนี้เป็นตัวทำให้หน้าเว็บว่างเปล่าโดยที่ console ไม่มีอะไรแดงเลย
ฝั่ง array ใช้กติกาเดียวกันเป๊ะ:
const team: string[] = ["Alice", "Bob", "Carol", "Dave"];
console.log("9)", team.slice(0, 2));
console.log("10)", team.slice(-2));
console.log("11)", team);
$ node c-slice.js (ตัดมาเฉพาะสามบรรทัดนี้)
9) [ 'Alice', 'Bob' ]
10) [ 'Carol', 'Dave' ]
11) [ 'Alice', 'Bob', 'Carol', 'Dave' ]
บรรทัด 11 คือประเด็นทั้งหมดของหัวข้อถัดไป: ตัดไปสองรอบแล้ว ของเดิมยังครบ 4 คน
5 · slice ของ TypeScript ไม่ใช่ slice ของ Go
คุณเรียน Go มาเก้าวัน และในบทเรียน 06 คุณเจอเรื่องนี้มาแล้ว —
ใน Go การเฉือน slice ไม่ได้ก๊อปข้อมูล มันคืนหน้าต่างที่มองไปยังก้อนความจำก้อนเดิม
เขียนทับผ่านหน้าต่างใหม่ ของเดิมเปลี่ยนตาม ผมรันให้ดูจริงด้วย go1.26.5:
Go — แชร์ความจำกัน
func main() {
s1 := []string{"Alice", "Bob", "Carol", "Dave"}
s2 := s1[1:3]
s2[0] = "BOB"
fmt.Println("s2 =", s2)
fmt.Println("s1 =", s1)
}
$ go run main.go
s2 = [BOB Carol]
s1 = [Alice BOB Carol Dave]
s1 โดนแก้ไปด้วย ทั้งที่โค้ดไม่ได้แตะ s1 เลยสักบรรทัด
TypeScript — ก๊อปออกมาใหม่
const s1: string[] = ["Alice", "Bob", "Carol", "Dave"];
const s2 = s1.slice(1, 3);
s2[0] = "BOB";
console.log("1) s2 =", s2);
console.log("2) s1 =", s1);
$ node e-shallow.js
1) s2 = [ 'BOB', 'Carol' ]
2) s1 = [ 'Alice', 'Bob', 'Carol', 'Dave' ]
s1 ไม่ขยับ — slice คืน array ก้อนใหม่ที่แยกขาดจากของเดิม
โค้ดหน้าตาเกือบเหมือนกัน ชื่อคำสั่งเหมือนกัน แต่ผลตรงข้ามกัน
ถ้าคุณพก mental model ของ Go ข้ามมา คุณจะกลัวการ slice โดยไม่จำเป็น
หรือแย่กว่านั้น — คุณจะไปเขียน Go ด้วย mental model ของ JS แล้วโดนของเก่าเปลี่ยนใต้เท้า
คุณมีหนังสือหนึ่งเล่ม อยากได้เนื้อหาหน้า 2 ถึง 5
· TypeScript ทำแบบ "เดินไปถ่ายเอกสาร" — ได้กระดาษชุดใหม่ 4 แผ่นมาถือ
คุณจะขีดเขียนบนกระดาษที่ถ่ายมายังไงก็ได้ หนังสือต้นฉบับบนชั้นไม่รู้เรื่องด้วย
· Go ทำแบบ "หนีบที่คั่นสองอัน" — ยังเป็นหนังสือเล่มเดิม
คุณแค่ทำเครื่องหมายว่าสนใจช่วงนี้ ขีดเขียนลงไป = ขีดเขียนลงหนังสือจริง
ที่ไม่เหมือนกัน: การถ่ายเอกสารในชีวิตจริง ก๊อปทุกอย่างที่อยู่บนหน้ากระดาษไปหมด
แต่ slice ก๊อปแค่ "รายชื่อของที่อยู่ในช่อง" ไม่ได้ก๊อปตัวของ —
ถ้าในช่องเป็น object การแก้ object ผ่านก้อนใหม่ยังไปโดนของเดิมอยู่ดี
ประเด็นนี้สำคัญพอที่จะมีกล่องเตือนของตัวเองข้างล่าง
⚠️ slice ก๊อป "ชั้นเดียว" — ถ้าข้างในเป็น object ยังแชร์กันอยู่
type Member = { name: string };
const teamObj: Member[] = [{ name: "Alice" }, { name: "Bob" }, { name: "Carol" }];
const part = teamObj.slice(0, 2);
part[0]!.name = "ALICE";
console.log("3) part =", part);
console.log("4) teamObj =", teamObj);
console.log("5) เป็นก้อนเดียวกันไหม:", part[0] === teamObj[0]);
$ node e-shallow.js
3) part = [ { name: 'ALICE' }, { name: 'Bob' } ]
4) teamObj = [ { name: 'ALICE' }, { name: 'Bob' }, { name: 'Carol' } ]
5) เป็นก้อนเดียวกันไหม: true
ตอนข้างในเป็น string การเขียนทับ s2[0] = "BOB" คือเปลี่ยนว่าช่องนั้นชี้ไปที่ไหน ของเดิมจึงไม่ขยับ
แต่ part[0].name = "ALICE" คือเข้าไปแก้ข้างในของที่ช่องนั้นชี้อยู่ —
และของชิ้นนั้นเป็นชิ้นเดียวกับที่ teamObj[0] ชี้อยู่ (บรรทัด 5 ยืนยันด้วย true)
สรุป: slice ทำให้ "กล่อง" แยกกัน แต่ "ของในกล่อง" ยังเป็นชิ้นเดียวกัน
6 · slice กับ splice — ต่างกันตัวเดียว แต่คนละเรื่องเลย
slice กับ splice ต่างกันแค่ตัว p ตัวเดียว
พิมพ์ผิดไม่มีอะไรเตือน เพราะมันมีอยู่จริงทั้งคู่
ลองทายก่อนดูเฉลย:
const a: string[] = ["Alice", "Bob", "Carol", "Dave"];
const x = a.slice(1, 3);
const b: string[] = ["Alice", "Bob", "Carol", "Dave"];
const y = b.splice(1, 3);
console.log("x =", x, "| a เหลือ", a.length, "ตัว");
console.log("y =", y, "| b เหลือ", b.length, "ตัว");
array เริ่มต้นเหมือนกันเป๊ะ ตัวเลขในวงเล็บก็ (1, 3) เหมือนกัน — x กับ y ได้เท่ากันไหม? แล้ว a กับ b เหลือกี่ตัว?
$ node p2.js
x = [ 'Bob', 'Carol' ] | a เหลือ 4 ตัว
y = [ 'Bob', 'Carol', 'Dave' ] | b เหลือ 1 ตัว
ต่างกันทั้งสองด้าน ไม่มีอะไรตรงกันเลย:
- ของที่ได้กลับมาไม่เท่ากัน —
xได้ 2 ชิ้นyได้ 3 ชิ้น เพราะเลขตัวที่สองแปลคนละความหมายslice(1, 3)= "จากตำแหน่ง 1 ถึงก่อนถึงตำแหน่ง 3" → 2 ชิ้นsplice(1, 3)= "จากตำแหน่ง 1 เอาออกไป 3 ชิ้น" → 3 ชิ้น - ของเดิมไม่เท่ากัน —
aยังครบ 4 แต่bเหลือ 1 เพราะspliceควักออกจากของเดิมจริง ๆ
อีกจุดที่ทำให้หลงง่าย: splice คืนของที่ควักออกมา ไม่ใช่ array ที่เหลือ
คนที่เขียน const remaining = list.splice(0, 1) โดยตั้งใจว่าจะได้ "รายการที่เหลือ"
จะได้แค่ชิ้นแรกที่ถูกลบ และ list ก็โดนแก้ไปแล้วเรียบร้อย
อีกด้านหนึ่ง splice แทรกของใหม่ได้ด้วย ซึ่ง slice ทำไม่ได้เลย:
const e: string[] = ["Alice", "Bob", "Dave"];
const removed = e.splice(2, 0, "Carol"); // ที่ตำแหน่ง 2 ลบ 0 ชิ้น แล้วยัด "Carol" เข้าไป
console.log("7) splice คืน:", removed);
console.log("8) ของเดิม :", e);
$ node d-splice.js (ตัดมาสองบรรทัด)
7) splice คืน: []
8) ของเดิม : [ 'Alice', 'Bob', 'Carol', 'Dave' ]
ตรงนี้ยิ่งชัด — ผลของงานอยู่ที่ e ไม่ได้อยู่ที่ค่าที่คืนมา (ซึ่งเป็น array ว่าง)
ถ้าคุณเขียน const newList = e.splice(2, 0, "Carol") คุณจะได้ [] แล้วงงว่าของหายไปไหน
slice | splice | |
|---|---|---|
| ของเดิม | ไม่แตะ | โดนแก้ |
| คืนอะไร | ส่วนที่ตัดมา (ก้อนใหม่) | ส่วนที่ควักทิ้ง |
| เลขตัวที่สอง | ตำแหน่งที่จะหยุดก่อนถึง | จำนวนชิ้นที่จะลบ |
| เลขติดลบ | ได้ทั้งสองตัว (นับจากท้าย) | ได้เฉพาะเลขตัวแรก · เลขตัวที่สองติดลบ = ลบ 0 ชิ้น |
| ใช้กับ string | ได้ | ไม่มี |
| แทรกของใหม่ | ไม่ได้ | ได้ |
แถวรองสุดท้าย — string ไม่มี splice เพราะข้อความใน JavaScript แก้ไม่ได้เลย
ทุกคำสั่งของ string จึงคืนข้อความก้อนใหม่เสมอ ไม่มีทางเลือกอื่น
และ TypeScript จับให้ตั้งแต่ยังไม่รัน พร้อมเดาให้ด้วยว่าคุณน่าจะหมายถึงอะไร:
const errorMessages: string = "Hello World";
const cut = errorMessages.splice(2, 6);
$ tsc --noEmit --strict d2-string-splice.ts
d2-string-splice.ts(3,27): error TS2551: Property 'splice' does not exist on type 'string'. Did you mean 'slice'?
Did you mean 'slice'? — tsc พิมพ์ให้เองเลย
นี่คือคุณค่าของ TypeScript แบบเห็นกับตา: ใน JavaScript ล้วน ๆ บรรทัดนี้จะระเบิดตอนรัน
ด้วยข้อความ errorMessages.splice is not a function ซึ่งเกิดขึ้นหลังจากผู้ใช้กดปุ่มไปแล้ว
- mutate / mutating method
- คำสั่งที่เข้าไปแก้ของเดิม ไม่ใช่คืนของใหม่ —
splice,sort,reverse,pushอยู่กลุ่มนี้
พิมพ์ถาม AI ได้ว่า: "method ของ Array ตัวไหนบ้างที่ mutate ของเดิม และมีตัวไหนที่มีคู่แฝดแบบไม่ mutate ให้ใช้แทน"
(คำตอบที่คุณจะได้จะมีtoSortedกับtoSplicedอยู่ด้วย — เป็นของใหม่ที่ Node 24 มีแล้ว) - immutable / non-mutating
- ตรงข้ามกับ mutate — คืนของก้อนใหม่ ของเดิมอยู่ครบ ·
slice,map,filter,joinอยู่กลุ่มนี้
คำนี้คือหัวใจของคำว่า Functional Programming ในชื่อวิชานี้ — และเป็นเหตุผลที่ React ถึงบังคับให้คุณสร้าง array ใหม่แทนการpushของเดิม
ถาม senior ได้ว่า: "ทำไม React ถึงไม่เห็นการเปลี่ยนแปลงเวลาผมpushเข้า state โดยตรง" - shallow copy (ก๊อปชั้นเดียว)
- ก๊อปเฉพาะชั้นนอกสุด ของที่อยู่ข้างในยังเป็นชิ้นเดิมร่วมกัน — คือสิ่งที่
sliceทำ
ตรงข้ามคือ deep copy ที่ก๊อปลงไปทุกชั้น (ใน Node 24 ใช้structuredClone()ได้)
พิมพ์บอก AI ได้ว่า: "ผม slice array ของ object ออกมาแล้วแก้ ปรากฏว่าของเดิมเปลี่ยนตาม ช่วยอธิบายว่าเป็น shallow copy ยังไง และควรแก้ด้วยอะไร"
7 · TypeScript ช่วยตรงไหนอีก
นอกจากจับ splice บน string ได้ มันยังจับรูปร่างของผลลัพธ์ให้ด้วย
กับดักคลาสสิกคือลืมว่า join คืน string ไม่ใช่ array:
const team: string[] = ["Alice", "Bob"];
const label: string[] = team.join(", ");
$ tsc --noEmit --strict g-typeerr.ts
g-typeerr.ts(3,7): error TS2322: Type 'string' is not assignable to type 'string[]'.
ในกล่องโค้ดข้างบน string[] อ่านว่า "array ของ string"
(เขียนแบบเต็มยศได้ว่า Array<string> ก็ความหมายเดียวกัน)
error บอกตรง ๆ ว่าฝั่งขวาเป็น string เดี่ยว ๆ แต่ฝั่งซ้ายประกาศไว้ว่าจะรับ array —
ใส่กันไม่ได้
| เขียน | ได้ type อะไร |
|---|---|
["a","b"].join(", ") | string |
"a, b".split(", ") | string[] |
"Hello".slice(1, 3) | string |
["a","b"].slice(0, 1) | string[] |
["a","b"].splice(0, 1) | string[] (ของที่ควักออก) |
slice เป็นตัวเดียวในตารางที่ type ขาออกเปลี่ยนตาม type ขาเข้า —
ใส่ string ได้ string ใส่ array ได้ array
8 · เอามาต่อกันใช้จริง
งานที่เจอบ่อยที่สุดคือ: ผู้ใช้พิมพ์รายชื่อมาในช่องเดียว เว้นวรรคมั่ว ๆ แล้วคุณต้องเอาไปแสดงแค่ 3 ชื่อแรก
const raw: string = "Alice, Bob ,Carol, Dave";
const names: string[] = raw.split(",");
console.log("1)", names);
const cleaned: string[] = raw.split(",").map((n) => n.trim());
console.log("2)", cleaned);
const firstThree: string[] = cleaned.slice(0, 3);
console.log("3)", firstThree);
const label: string = firstThree.join(" · ");
console.log("4)", label);
console.log("5) ของเดิมยังอยู่:", raw);
const long = "รายการวัตถุดิบทั้งหมดของสูตรอาหารนี้";
console.log("6)", long.slice(0, 12) + "…");
$ node f-pipeline.js
1) [ 'Alice', ' Bob ', 'Carol', ' Dave' ]
2) [ 'Alice', 'Bob', 'Carol', 'Dave' ]
3) [ 'Alice', 'Bob', 'Carol' ]
4) Alice · Bob · Carol
5) ของเดิมยังอยู่: Alice, Bob ,Carol, Dave
6) รายการวัตถุด…
ทางเดินคือ split → ล้าง → slice → join —
แปลงเป็น array เพื่อจัดการ แล้วแปลงกลับเป็น string เพื่อแสดงผล
บรรทัด 5 คือสิ่งที่ผมอยากให้สังเกต: ผ่านมาสี่ขั้นตอนแล้ว raw ยังเหมือนเดิมเป๊ะ
ทุกขั้นตอนคืนของก้อนใหม่หมด ถ้าอยากย้อนกลับไปเริ่มใหม่ก็ทำได้ตลอดเวลา
บรรทัด 6 เป็นการใช้ slice ที่คุณจะเจอทุกโปรเจกต์: ตัดข้อความยาวให้พอดีการ์ด
แต่ระวัง — ถ้าข้อความเป็นภาษาไทย slice(0, 12) อาจตัดกลางสระได้ ด้วยเหตุผลเดียวกับกล่องเตือนในหัวข้อ 3
9 · ทวนความเข้าใจ
1. "Hello World".split(",") — ข้อความนี้ไม่มีคอมมาอยู่เลยสักตัว จะได้อะไรกลับมา?
ผลรันจริง (บรรทัด 6 ของ b-split.js): [ 'Hello World' ] และ .length เป็น 1
split คิดแบบนี้: "ตัดตรงที่เจอ delimiter" — เจอ 0 จุด ก็ตัด 0 ครั้ง เหลือชิ้นเดียวคือทั้งก้อน
ตัวเลือกที่ 1 ผิด — split จะคืน array ว่างได้แค่สองกรณี คือ "".split("")
(บรรทัด 11 ในผลรัน) กับตอนที่สั่ง limit เป็น 0
(รันแล้ว: "Hello World".split(",", 0) ได้ []) — ทั้งคู่คนละเรื่องกับข้อนี้
ตัวเลือกที่ 2 ผิด — split ไม่เคยโยน error เรื่อง delimiter หาไม่เจอ มันเงียบเสมอ ซึ่งคือปัญหา
ตัวเลือกที่ 4 ผิด — split คืน string[] เสมอ ไม่เคยคืน null และ TypeScript ก็ประกาศ type ไว้แบบนั้น
จุดที่ต้องจำ: split ที่หา delimiter ไม่เจอ ไม่ได้ว่างเปล่าและไม่ได้พัง — มันคืน array ยาว 1 ที่มีของทั้งก้อน แล้วปล่อยให้คุณไปพังทีหลัง
2. const a = ["Alice","Bob","Carol","Dave"] แล้วเรียก a.splice(1, 3) — หลังบรรทัดนี้ a เหลืออะไร และค่าที่คืนกลับมาคืออะไร?
ผลรันจริงจาก p2.js: y = [ 'Bob', 'Carol', 'Dave' ] | b เหลือ 1 ตัว
มีสองเรื่องเกิดพร้อมกัน: (ก) เลข 3 ใน splice คือจำนวนชิ้นที่ลบ ไม่ใช่ตำแหน่งสิ้นสุด
จึงลบ Bob, Carol, Dave ครบสามชิ้น · (ข) splice แก้ของเดิม และคืนของที่ควักออก
ตัวเลือกที่ 1 ผิดทั้งสองเรื่อง — เป็นพฤติกรรมของ slice ไม่ใช่ splice (รันเทียบกันแล้วใน p2.ts)
ตัวเลือกที่ 3 ผิดครึ่งเดียว — a เหลือ ["Alice"] ถูก แต่ค่าที่คืนคือของที่ลบ ไม่ใช่ของที่เหลือ
ตัวเลือกที่ 4 ผิด — คิดว่าเลข 3 เป็นตำแหน่งสิ้นสุด จึงเหลือ Dave ไว้ ซึ่งจริง ๆ Dave ถูกลบไปด้วย
จุดที่ต้องจำ: splice(เริ่ม, จำนวนที่ลบ) แก้ของเดิมและคืนของที่ลบ ส่วน slice(เริ่ม, ก่อนถึง) ไม่แตะของเดิมและคืนของที่ตัดมา
3. คุณเรียน Go มาแล้วรู้ว่า s2 := s1[1:3] แล้วเขียนทับ s2[0] จะไปโดน s1 ด้วย — ใน TypeScript const s2 = s1.slice(1, 3) แล้ว s2[0] = "BOB" จะเกิดอะไรขึ้น?
ผมรันเทียบสองภาษาบนเครื่องเดียวกัน:
· Go (go1.26.5): s1 = [Alice BOB Carol Dave] — โดนแก้
· TypeScript (node v24.14.0): s1 = [ 'Alice', 'Bob', 'Carol', 'Dave' ] — ไม่ขยับ
และเคส object: part[0].name = "ALICE" ทำให้ teamObj[0] เปลี่ยนตาม
พร้อม part[0] === teamObj[0] ได้ true
ตัวเลือกที่ 1 ผิด — เป็นความเข้าใจที่ยกจาก Go มาตรง ๆ ซึ่งเป็นกับดักหลักของบทนี้
ตัวเลือกที่ 2 ผิด — const ห้ามแค่ชี้ไปที่ array ก้อนใหม่ ไม่ได้ห้ามแก้ช่องข้างใน (โค้ดนี้ผ่าน tsc --strict)
ตัวเลือกที่ 3 ผิดกลับด้าน — string/number คือกรณีที่ปลอดภัย ส่วน object คือกรณีที่ยังแชร์กัน
จุดที่ต้องจำ: slice ของ TypeScript ก๊อปกล่องออกมาใหม่ (ไม่เหมือน Go ที่แชร์) แต่ก๊อปแค่ชั้นเดียว — ของในกล่องยังเป็นชิ้นเดิม
4. อยากได้ "ทุกตัวยกเว้นตัวสุดท้าย" จาก string ที่ไม่รู้ความยาวล่วงหน้า — เขียนยังไงสั้นที่สุดและไม่พัง?
ผลรันจริง: "Hello World".slice(-5, -1) ได้ "Worl" — ยืนยันว่าเลขลบตัวหลังคือ "หยุดก่อนถึงตัวท้าย"
ตัวเลือกที่ 2 ผิด — slice(-1) คือ "เอา 1 ตัวสุดท้าย" ได้ตรงข้ามกับที่ต้องการ
(ทดสอบแล้ว: slice(-5) ได้ "World" ไม่ใช่ "Hello ")
ตัวเลือกที่ 3 ผิด — string ไม่มี splice เลย tsc ฟ้อง
TS2551: Property 'splice' does not exist on type 'string'. Did you mean 'slice'?
ตัวเลือกที่ 4 ผิด — เลขหน้ามากกว่าเลขหลังได้ข้อความว่างเสมอ ไม่มีการไล่ย้อน
(ทดสอบแล้ว: slice(6, 2) ได้ "")
จุดที่ต้องจำ: slice(0, -1) = ตัดตัวท้ายทิ้ง · slice(-1) = เอาแต่ตัวท้าย — สองอันนี้ตรงข้ามกัน อย่าสลับ
10 · ภารกิจ
เป้าหมายคือฟังก์ชันที่รับ string ดิบจากช่องกรอกของผู้ใช้ แล้วคืนข้อความสรุปสั้น ๆ
เช่นรับ "Alice, Bob ,Carol, Dave" แล้วคืน "Alice · Bob · Carol และอีก 1 คน"
ทำในโฟลเดอร์เปล่า ๆ ที่ไหนก็ได้ ขอแค่มี tsc กับ node
- สร้างไฟล์
summary.tsแล้วเขียนฟังก์ชันsummarize(raw: string, max: number): string
ข้างในให้ทำตามลำดับนี้:split(",")→map(n => n.trim())→filter(n => n !== "")→slice(0, max)→join(" · ")
ถ้ามีคนเกินmaxให้ต่อท้ายว่า" และอีก N คน"โดยNคำนวณจากจำนวนหลังfilterลบด้วยmax - ตรวจ type ก่อนรัน:
tsc --noEmit --strict summary.ts
ต้องได้ผลเงียบ ๆ ไม่มีข้อความอะไรออกมา ถ้ามี error ให้แก้จนหมดก่อนไปข้อถัดไป - ลองใส่กับดักดูว่า tsc จับได้จริงไหม: เปลี่ยน
sliceเป็นspliceในบรรทัดที่ทำงานกับ array แล้วรันtsc --noEmit --strictซ้ำ
คุณจะพบว่ามันไม่ฟ้องเลย เพราะ array มีspliceจริง — นี่คือเหตุผลที่ข้อถัดไปจำเป็น แล้วค่อยเปลี่ยนกลับเป็นslice - เขียนเทสพิสูจน์ว่าของเดิมไม่โดนแก้ — สร้าง
summary.test.tsแล้วใช้ test runner ที่ Node มีมาให้ในตัว (import { test } from "node:test"และimport assert from "node:assert/strict") เขียนอย่างน้อย 4 เคส:- เคสปกติ 4 ชื่อ
maxเป็น 3 → ต้องได้"Alice · Bob · Carol และอีก 1 คน" - เคสมีคอมมาเกินท้ายข้อความ
"Alice,Bob,"→ ต้องได้"Alice · Bob"(ข้อนี้พิสูจน์ว่าfilterทำงาน — ถ้าลืมใส่จะได้จุดคั่นลอย ๆ ท้ายข้อความ) - เคสข้อความว่าง
""→ ต้องได้"" - เคสสำคัญที่สุด: ประกาศ
const raw = "Alice, Bob ,Carol, Dave"เก็บสำเนาความยาวไว้ เรียกsummarize(raw, 2)แล้วassert.equal(raw, "Alice, Bob ,Carol, Dave")
ข้อนี้คือข้อที่จะแดงทันทีถ้าคุณเผลอใช้spliceในข้อ 3 แล้วลืมเปลี่ยนกลับ
- เคสปกติ 4 ชื่อ
- รันเทสจริง: คอมไพล์ด้วย
tsc --target es2022 --module nodenext --moduleResolution nodenext summary.ts summary.test.tsแล้วnode --test
ต้องเห็นบรรทัดfail 0และเลขหลังpassเท่ากับจำนวนเทสที่คุณเขียน ถึงจะถือว่าผ่าน
เคสที่ 4 หลายคนเขียนแล้วมันผ่านตลอดแม้จะใช้ splice — เพราะเผลอส่ง array ที่สร้างใหม่เข้าไปแทนที่จะส่ง raw
จุดที่ต้องระวัง: raw เป็น string ซึ่งแก้ไม่ได้อยู่แล้ว ดังนั้นเทสจะไม่มีวันแดง
ถ้าเทียบแค่ตัว raw
ให้เพิ่มอีกเทสหนึ่งที่รับ array เข้าไปตรง ๆ แทน:
เขียนฟังก์ชันคู่แฝด summarizeList(names: string[], max: number): string
ที่ข้างในใช้ slice แล้วเทสว่า names.length ก่อนเรียกกับหลังเรียกเท่ากัน
ตอนนี้แหละที่การสลับเป็น splice จะทำให้เทสแดงจริง — และนั่นคือสิ่งที่ภารกิจนี้อยากให้คุณเห็นกับตา
ติดตรงไหนถามกลับมาได้เลย — โดยเฉพาะถ้า node --test หาไฟล์เทสไม่เจอ
(ปกติเป็นเพราะชื่อไฟล์ที่คอมไพล์ออกมาไม่ได้ลงท้ายด้วย .test.js)
11 · สรุปสั้น ๆ
join= array → string · ตัวในวงเล็บคือตัวคั่นระหว่างชิ้น ·join()เปล่า ๆ ได้คอมมา ไม่ใช่ไม่มีอะไรsplit= string → array · หา delimiter ไม่เจอไม่ error แต่คืน array ยาว 1 ·split()เปล่า ๆ tsc ฟ้องTS2554split("")แบ่งตามหน่วยในหน่วยความจำ ไม่ใช่ตามที่ตาเห็น —"กิน"ได้ 3 ชิ้นslice(เริ่ม, ก่อนถึง)ใช้ได้ทั้ง string และ array · ความยาวผลลัพธ์ = เลขหลัง − เลขหน้า · เลขติดลบ = นับจากท้ายsliceไม่แตะของเดิม ·spliceแก้ของเดิมและคืนของที่ลบ · string ไม่มีsplice- slice ของ Go แชร์ความจำ · slice ของ TypeScript ก๊อปใหม่ — แต่ก๊อปชั้นเดียว object ข้างในยังแชร์กัน
บทหน้า (67) จะเป็น map · filter · reduce
ซึ่งอยู่กลุ่ม "ไม่แตะของเดิม" เหมือน slice ทั้งหมด —
คำว่า immutable ที่ติดป้ายไว้ในบทนี้จะกลับมาเป็นตัวเอกของบทนั้น
ตรงไหนที่อ่านแล้วยังไม่เห็นภาพ ถามกลับมาได้เลย — โดยเฉพาะเรื่อง Go เทียบ TypeScript ในหัวข้อ 5 ซึ่งเป็นจุดที่คนเรียน Go มาก่อนสะดุดกันเกือบทุกคน
อ้างอิง
- MDN —
Array.prototype.slice()— อ่านหัวข้อ Parameters ให้จบ จะเจอประโยคที่อธิบายเลขติดลบไว้ตรง ๆ และประโยคที่บอกว่ามันเป็น shallow copy ซึ่งคือกล่องเตือนในหัวข้อ 5 ของบทนี้ - MDN —
Array.prototype.splice()— ดูเฉพาะหัวข้อ Return value สองบรรทัด จะเห็นว่ามันคืน "array ของสิ่งที่ถูกลบ" ไม่ใช่ array ที่เหลือ — ต้นเหตุของบั๊กในควิซข้อ 2 - MDN —
String.prototype.split()— อ่านย่อหน้า Description เรื่อง empty string ที่อธิบายว่าทำไม"".split(",")กับ"".split("")ให้ผลไม่เท่ากัน และคำเตือนเรื่อง surrogate pair ที่ทำให้ emoji แตกเป็นหลายชิ้น - MDN —
Array.prototype.join()— อ่านสองประโยคแรกของ Description ที่บอกว่าnullและundefinedจะกลายเป็นข้อความว่าง — คือที่มาของa---bในหัวข้อ 2 - Go Blog — Go Slices: usage and internals —
อ่านหัวข้อ Slice internals ย่อหน้าเดียว เพื่อยืนยันด้วยตาตัวเองว่า
slice ของ Go คือ pointer ที่ชี้ไป array ก้อนเดิม — คนละเรื่องกับ
sliceของ TypeScript ที่ก๊อปออกมาใหม่ - TypeScript Handbook — Everyday Types: Arrays —
อ่านย่อหน้าสั้น ๆ ตรงหัวข้อ Arrays เพื่อดูว่า
string[]กับArray<string>เป็นของอย่างเดียวกัน ต่างกันแค่วิธีเขียน