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

แปลงข้อมูล — join, split, slice

~20 นาที · ควรผ่าน บทเรียน 65 (ใส่ type ให้ฟังก์ชัน) มาก่อน · ถ้าเคยอ่านเรื่อง slice ของ Go ในบทเรียน 06 มาแล้วจะยิ่งได้เปรียบ เพราะบทนี้จะชี้ให้เห็นว่ามันคนละเรื่องกัน

บทนี้มาจากสไลด์ TypeScript หน้า 29–32 หัวข้อ Transform Element ซึ่งสอนสามคำสั่ง: join · split · slice
สามคำสั่งนี้ทำงานง่ายมาก — array กลายเป็น string, string กลายเป็น array, และตัดเอาบางส่วนออกมา
แต่มันมีกับดักซ่อนอยู่สี่อันที่สไลด์ไม่ได้พูดถึง และทุกอันเคยทำให้โปรแกรมพังจริง: slice ที่หน้าตาเหมือน splice แต่ทำคนละเรื่อง · เลขติดลบที่หลายคนไม่รู้ว่าใส่ได้ · split("") กับ split(" ") ที่ต่างกันแค่เคาะเดียวแต่ได้ผลคนละโลก · และเรื่องใหญ่ที่สุดสำหรับคุณ — คำว่า slice ใน Go กับใน TypeScript ไม่ใช่ของอย่างเดียวกัน
ทุกผลลัพธ์ในบทนี้ผมรันจริงแล้วก็อปมาวาง ไม่ได้พิมพ์จากความจำ

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

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 · ปัญหาที่สามคำสั่งนี้เกิดมาแก้

ข้อมูลในโปรแกรมเว็บมีสองรูปร่างที่เจอบ่อยที่สุด:

ปัญหาคือของที่คุณได้มา มักอยู่ในรูปร่างที่ผิดกับสิ่งที่คุณจะทำต่อ
ผู้ใช้พิมพ์ "Alice, Bob, Carol" มาให้ในช่องเดียว แต่คุณอยากนับว่ากี่คน — ต้องแปลงเป็น array ก่อน
คุณมี array ของชื่อ แต่จะเอาไปโชว์ใต้ชื่อสูตรอาหาร — ต้องแปลงกลับเป็น string ก่อน

คำสั่งรับอะไรมาคืนอะไรกลับพูดสั้น ๆ
joinarraystringร้อยหลายชิ้นให้เป็นก้อนเดียว
splitstringarrayผ่าก้อนเดียวให้เป็นหลายชิ้น
slicestring หรือ 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

อ่านผลทีละบรรทัดแล้วจะได้กฎครบชุด:

🧰 เทียบให้เห็นภาพ — ร้อยพวงมาลัย กับ แกะพวงมาลัย

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

สามอันที่ต้องหยุดดู:

⚠️ กับดักคนไทย: 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 " จะมองไม่ออกเลยว่ามีอยู่)

ฝั่ง 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") คุณจะได้ [] แล้วงงว่าของหายไปไหน

slicesplice
ของเดิมไม่แตะโดนแก้
คืนอะไรส่วนที่ตัดมา (ก้อนใหม่)ส่วนที่ควักทิ้ง
เลขตัวที่สองตำแหน่งที่จะหยุดก่อนถึงจำนวนชิ้นที่จะลบ
เลขติดลบได้ทั้งสองตัว (นับจากท้าย)ได้เฉพาะเลขตัวแรก · เลขตัวที่สองติดลบ = ลบ 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

  1. สร้างไฟล์ 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
  2. ตรวจ type ก่อนรัน: tsc --noEmit --strict summary.ts
    ต้องได้ผลเงียบ ๆ ไม่มีข้อความอะไรออกมา ถ้ามี error ให้แก้จนหมดก่อนไปข้อถัดไป
  3. ลองใส่กับดักดูว่า tsc จับได้จริงไหม: เปลี่ยน slice เป็น splice ในบรรทัดที่ทำงานกับ array แล้วรัน tsc --noEmit --strict ซ้ำ
    คุณจะพบว่ามันไม่ฟ้องเลย เพราะ array มี splice จริง — นี่คือเหตุผลที่ข้อถัดไปจำเป็น แล้วค่อยเปลี่ยนกลับเป็น slice
  4. เขียนเทสพิสูจน์ว่าของเดิมไม่โดนแก้ — สร้าง 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 แล้วลืมเปลี่ยนกลับ
  5. รันเทสจริง: คอมไพล์ด้วย 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 · สรุปสั้น ๆ

บทหน้า (67) จะเป็น map · filter · reduce ซึ่งอยู่กลุ่ม "ไม่แตะของเดิม" เหมือน slice ทั้งหมด — คำว่า immutable ที่ติดป้ายไว้ในบทนี้จะกลับมาเป็นตัวเอกของบทนั้น

ตรงไหนที่อ่านแล้วยังไม่เห็นภาพ ถามกลับมาได้เลย — โดยเฉพาะเรื่อง Go เทียบ TypeScript ในหัวข้อ 5 ซึ่งเป็นจุดที่คนเรียน Go มาก่อนสะดุดกันเกือบทุกคน

อ้างอิง