บันทึกวิจัย
สรุปบทความ LLMs Will Cheese Your Types ของ Justin Le เขาเขียน Haskell ร่วมกับ AI แล้วเก็บสถิติว่า AI มีท่าหลบอะไรบ้างเวลาเจอ type ที่เข้มงวด ทุกท่าคอมไพล์ผ่านหมด รีวิวผ่านตาเร็ว ๆ ก็ดูปกติ แต่มันแอบทำลายสิ่งที่เราตั้งใจสร้างไว้ทีละนิด บทความนี้ยกโค้ดมาให้ครบทุกตัวอย่าง พร้อมเวอร์ชัน Scala 3 และ TypeScript คู่กันทุกท่า
ลองนึกภาพเกมที่มีกำแพงกั้นไม่ให้ตัวละครเดินลัดไปโซนสุดท้าย แล้ววันหนึ่งมีคนค้นพบว่า ถ้ากระโดดมุมนี้แล้วชนกำแพงองศานี้ ตัวละครจะทะลุออกไปเลย วงการเกมเรียกท่าแบบนี้ว่า "ชีส" (cheese) คือเอาชนะด่านโดยไม่ได้เล่นตามที่คนออกแบบตั้งใจ ไม่ได้โกงถึงขั้นแฮ็ก แต่ก็ไม่ได้ผ่านด่านจริง ๆ
ชื่อบทความต้นฉบับคือ "LLMs Will Cheese Your Types" แปลว่า "AI จะชีสระบบ type ของคุณ" ครับ
แกะคำศัพท์
Type อ่านว่า "ไทป์" แปลว่า "ชนิดของข้อมูล" คือการบอกภาษาโปรแกรมล่วงหน้าว่าตัวแปรนี้เก็บอะไรได้บ้าง ถ้าใครเผลอยัดของผิดชนิดลงไป โปรแกรมจะไม่ยอมคอมไพล์ตั้งแต่แรก
Haskell อ่านว่า "แฮสเคลล์" เป็นภาษาโปรแกรมสาย functional ที่ขึ้นชื่อเรื่องระบบ type ที่เข้มงวดและแสดงออกได้ละเอียดมาก ตั้งชื่อตามนักตรรกศาสตร์ Haskell Curry ("แฮสเคลล์ เคอร์รี") จุดขายของมันคือ ถ้าออกแบบ type ดีพอ สถานะที่ผิดจะเขียนออกมาไม่ได้เลย
LLM ย่อจาก Large Language Model อ่านว่า "ลาร์จ แลงเกวจ โมเดล" แปลว่า "โมเดลภาษาขนาดใหญ่" ก็คือ AI แบบ Claude/ChatGPT ที่เราใช้เขียนโค้ดกันนี่แหละ
อ่านโค้ด Haskell ไม่ออกก็ไม่เป็นไร
ทุกท่าในบทความนี้ผมยกโค้ดต้นฉบับ Haskell มาให้ครบ แล้วเขียนเวอร์ชัน Scala 3 กับ TypeScript คู่กันทุกอัน ทุกฝั่งใช้ไลบรารีสาย functional ให้เต็มที่ (Scala ใช้ cats / cats-effect สำหรับ IO, fs2 สำหรับ stream, Pekko สำหรับ actor ; TypeScript ใช้ Effect-TS ที่มี Effect, Stream, Data, Brand ครบ) โค้ดแต่ละท่าอยู่ในกล่องที่มีแท็บให้กดสลับ Haskell / Scala / TypeScript เลือกอ่านฝั่งที่ถนัดได้เลยครับ ความหมายตรงกัน
อีกอย่าง ในโค้ดจะเจอเครื่องหมายย่อโค้ดอยู่บ่อย ๆ ฝั่ง Haskell ใช้ ... ฝั่ง Scala ใช้ ??? ส่วน TypeScript ใช้ todo() หรือ // ... ทั้งหมดแปลว่า "ตรงนี้มีโค้ดจริงอยู่ แต่ผมย่อไว้ให้ดูสั้น" ไม่ต้องไปงงกับมันครับ
Justin ใช้ AI ตัวไหน
ในบทความ Justin อ้างถึง Claude โดยตรง และเอ่ยถึงรุ่น Opus 4.8 ตอนพูดเรื่องการวางโครงโดเมน แต่เขาไม่ได้ประกาศชัดว่าโค้ดตัวอย่างทุกอันมาจากรุ่นเดียวกัน ส่วนใหญ่พูดกลาง ๆ ว่า "LLMs" หรือ "frontier models" ที่น่าคิดคือขนาด Opus 4.8 ซึ่งเป็นรุ่นเรือธง ก็ยังเจอท่าหลบพวกนี้
เพราะงั้นอ่านบทความนี้ในฐานะประสบการณ์และความเห็นของ Justin (บวกกับฝั่ง Scala ที่ผมเจอเอง) ไม่ใช่กฎตายตัว ถ้าเอาไปลองซ้ำกับโมเดลหรือเวอร์ชันอื่น ผลอาจต่างออกไปได้
Justin เริ่มจากมุมที่ผมว่าน่าสนใจมาก เขาบอกว่าในวงการคณิตศาสตร์ มีภาษาชื่อ Lean ("ลีน") ที่ใช้เขียนบทพิสูจน์แล้วให้คอมพิวเตอร์ตรวจให้ว่าถูกจริงไหม พอเอา AI มาช่วยพิสูจน์ทฤษฎีบท มันเลยเวิร์กมาก เพราะ AI ไม่ต้องเดาว่าตัวเองถูกหรือเปล่า มันลองมั่ว ๆ ไปเรื่อย แล้วให้ตัวตรวจสอบเป็นคนบอกว่ายังไม่ผ่าน วนไปจนกว่าจะผ่าน
เขาหวังว่า Haskell จะเล่นบทเดียวกันนี้ให้การเขียนโปรแกรม เราไม่ต้องหวังให้ AI เขียนถูกตั้งแต่ครั้งแรก (ซึ่งเขาบอกตรง ๆ ว่าความถูกต้องตั้งแต่ตอนสร้างนั้น ไม่สมจริง) เราแค่ต้องสร้างนั่งร้านที่บีบให้การมั่วของมันเดินไปในทางที่ถูกเท่านั้น
แต่พอลงมือจริง เขาเจอปัญหาว่า AI ชอบเดินอ้อมนั่งร้านมากกว่าปีนขึ้นไป
ต้นตอของปัญหา
AI ถูกเทรนด้วยโค้ดในโลกจริงเป็นหลัก ซึ่งส่วนใหญ่คือ Python กับ JavaScript ที่ไม่เข้มงวดเรื่อง type เลย นิสัย "หาทางที่เร็วที่สุดให้มันรันผ่าน ๆ ไปก่อน" เลยติดมาด้วย พอเจอกำแพง type มันจึงไม่ได้คิดว่ากำแพงนี้กำลังบอกอะไรฉัน แต่คิดว่าจะข้ามกำแพงนี้ยังไงให้เร็วที่สุด
ท่าที่ตรงไปตรงมาที่สุด คอมไพเลอร์เตือนว่า "เฮ้ย เคสนี้นายยังไม่ได้จัดการนะ" แล้ว AI ก็ไปเติมคำสั่งปิดคำเตือนนั้นซะ
{-# OPTIONS_GHC -Wno-incomplete-patterns #-}
-- หรือแบบเจาะจงจุด
{-# HLINT ignore "Use head" #-} import scala.annotation.nowarn
// ปิดคำเตือนทั้งไฟล์ผ่าน build.sbt
// scalacOptions += "-Wconf:msg=match may not be exhaustive:s"
// ปิดเฉพาะจุด
@nowarn("msg=match may not be exhaustive")
def classify(r: Region): String = r match
case Region.Canada => "north"
// หรือปิดปากคอมไพเลอร์ด้วย @unchecked / asInstanceOf
val Region.USState(st) = region: @unchecked
val name = raw.asInstanceOf[UserName] // ปิดทั้งไฟล์
// @ts-nocheck
// หรือเจาะจงบรรทัดเดียว
// @ts-expect-error
const region = classify(raw)
// หรือ cast ปิดปาก type checker ตรง ๆ
const name = raw as UserName
const anything = raw as any ฝั่ง Scala 3 ยิ่งมีให้เลือกเยอะกว่าด้วยซ้ำ เพราะมีทั้ง annotation, flag ใน build, และการ cast ตรง ๆ กดแท็บ Scala ดูได้เลยครับ
เปรียบเทียบง่าย ๆ คือรถขึ้นไฟเตือนน้ำมันเครื่อง แล้วเราแก้ด้วยการเอาเทปดำแปะทับไฟดวงนั้น รถวิ่งต่อได้ หน้าปัดสวยงามเหมือนเดิม
Justin ยอมรับว่า มีบางกรณี ที่การปิดคำเตือนสมเหตุสมผลจริง ๆ แต่ประเด็นคือ AI แทบไม่มีวิจารณญาณพอจะแยกได้ว่าตอนไหนควรตอนไหนไม่ควร เขาเลยมองว่าการตัดสินใจปิดเสียงเตือนควรเป็นสิทธิ์ของคนเท่านั้น
อันนี้เจ็บกว่า เพราะมันเกิดหลังจากที่เรานั่งวางแผนกับ AI มาอย่างดีแล้ว ตกลงกันเรียบร้อยว่าจะใช้ type แบบไหน พอถึงเวลาเขียนจริง มันเปลี่ยนเงียบ ๆ
ขอปูฉากก่อน สมมติเรากำลังทำระบบที่มีสามงานเล็ก ๆ งานแรก summarize สรุปรายงานจากตัวเลขชุดหนึ่ง เราตกลงกันว่ามันต้องมีตัวเลขอย่างน้อยหนึ่งตัว (ลิสต์ว่างสรุปอะไรไม่ได้) เลยใช้ type ที่ชื่อ NonEmpty การันตีตรงนี้ งานที่สอง notify ยิงแจ้งเตือนออกไปตามช่องทางที่มีจริงเท่านั้น (อีเมล / SMS / push) เลยทำ Channel เป็นตัวเลือกตายตัว งานที่สาม persist เซฟข้อมูลลงที่เก็บ เราตกลงว่ารับเฉพาะชนิดที่แปลงเป็นไบนารีได้ (Binary) จะได้เขียนลงไฟล์ได้จริง สามข้อนี้คือ "แผน" ที่เราคุยกับ AI ไว้ ทีนี้มาดูว่าพอเขียนจริงมันเป็นยังไง
-- ที่ตกลงกันไว้ตอนวางแผน
summarize :: NonEmpty Int -> Summary
data Channel = Email | Sms | Push -- ตัวเลือกตายตัว
notify :: Channel -> IO () -- รับเฉพาะช่องที่มีจริง
persist :: Binary a => a -> IO ()
-- ที่ AI เขียนออกมาจริง
summarize :: [Int] -> Summary -- ลิสต์ว่างเข้ามาได้
notify :: Text -> IO () -- พิมพ์ "emial" ก็ยังคอมไพล์ผ่าน
persist :: Show a => a -> IO () -- ใช้ข้อจำกัดที่หลวมกว่า import cats.data.NonEmptyList
import cats.Show
import io.circe.Encoder
// ที่ตกลงกันไว้ตอนวางแผน
def summarize(xs: NonEmptyList[Int]): Summary
enum Channel:
case Email, Sms, Push
def notify(channel: Channel): IO[Unit] // รับเฉพาะช่องที่มีจริง
def persist[A: Encoder](a: A): IO[Unit]
// ที่ AI เขียนออกมาจริง
def summarize(xs: List[Int]): Summary // List ว่างเข้ามาได้
def notify(channel: String): IO[Unit] // พิมพ์ "emial" ก็ยังคอมไพล์ผ่าน
def persist[A: Show](a: A): IO[Unit] // หา Encoder ไม่เจอ ก็สลับไปใช้ Show import { Data } from "effect"
import type { Effect } from "effect"
// ที่ตกลงกันไว้ตอนวางแผน
type NonEmptyArray<A> = readonly [A, ...A[]]
declare function summarize(xs: NonEmptyArray<number>): Summary
type Channel = "Email" | "Sms" | "Push" // ตัวเลือกตายตัว
declare function notify(channel: Channel): Effect.Effect<void>
declare function persist<A extends Encodable>(a: A): Effect.Effect<void>
// ที่ AI เขียนออกมาจริง
declare function summarize(xs: number[]): Summary // อาเรย์ว่างเข้ามาได้
declare function notify(channel: string): Effect.Effect<void> // "emial" ก็ยังผ่าน
declare function persist(a: unknown): Effect.Effect<void> // ข้อจำกัดหลุดหมด
ทั้งสามกรณีคือการลดความเข้มของข้อจำกัด NonEmpty Int หรือ NonEmptyList[Int] คือลิสต์ที่รับประกันว่ามีอย่างน้อยหนึ่งตัว พอกลายเป็นลิสต์ธรรมดา ภาระการเช็กว่า "แล้วถ้าว่างล่ะ" ก็เด้งกลับมาเป็นของเราทันที การเปลี่ยน Channel ที่เป็นตัวเลือกตายตัวให้กลายเป็น String ก็คือเปิดประตูให้คำที่พิมพ์ผิดอย่าง "emial" หลุดเข้ามาได้ทุกเมื่อ ส่วน persist ที่ลดจาก Binary เหลือ Show ก็คือแอบเปลี่ยนวิธีเซฟข้อมูลไปเป็นคนละอย่างเงียบ ๆ
จุดที่ Justin เน้นคือมันไม่ได้มาบอกเราว่า "ขอเปลี่ยนแผนนะ เพราะ..." ซึ่งถ้าบอกก็ยังคุยกันได้ แต่มันเปลี่ยนแบบเนียน ๆ ให้เราไปเจอเอาทีหลัง ซึ่งขัดกับความหมายของคำว่าวางแผนร่วมกันตั้งแต่ต้น
ท่านี้เป็นไฮไลต์ของบทความ และเป็นท่าที่ Justin ยกตัวอย่างไว้เยอะที่สุด ลองนึกภาพว่าเรากำลังทำเซิร์ฟเวอร์ที่คอยรับคำขอ (request) เข้ามาทำงาน เวลาอะไรพัง มันต้องตอบให้ได้ว่าพังเพราะอะไร เราเลยมี type ตัวหนึ่งชื่อ ErrorEvent ที่รวบรวม "ชนิดของความผิดพลาด" ที่เป็นไปได้ทั้งหมดไว้ในที่เดียว หน้าตาแบบนี้:
data ErrorEvent = UnknownUser String
| DatabaseErrorCode Int
| InvalidJSON A.Value
| NetworkError SomeException
| Canceled (Maybe CancelationReason)
| ...
handleRequest :: Request -> IO (Either ErrorEvent Response) import cats.effect.IO
import io.circe.Json
enum ErrorEvent:
case UnknownUser(name: String)
case DatabaseErrorCode(code: Int)
case InvalidJson(value: Json)
case NetworkError(cause: Throwable)
case Canceled(reason: Option[CancelationReason])
def handleRequest(req: Request): IO[Either[ErrorEvent, Response]] import { Data } from "effect"
import type { Effect } from "effect"
type ErrorEvent = Data.TaggedEnum<{
UnknownUser: { readonly name: string }
DatabaseErrorCode: { readonly code: number }
InvalidJson: { readonly value: unknown }
NetworkError: { readonly cause: Error }
Canceled: { readonly reason: CancelationReason | null }
}>
const { UnknownUser, DatabaseErrorCode, InvalidJson, NetworkError, Canceled } =
Data.taggedEnum<ErrorEvent>()
declare function handleRequest(req: Request): Effect.Effect<Response, ErrorEvent>
อ่านง่าย ๆ คือ error มีได้หลายแบบ ไม่รู้จักผู้ใช้ / ฐานข้อมูลพัง (พร้อมรหัสตัวเลข) / JSON เสีย / เน็ตล่ม / ถูกยกเลิก ส่วน IO[Either[ErrorEvent, Response]] อ่านว่า "งานที่มีผลข้างเคียง ซึ่งจบแล้วจะได้ error หรือไม่ก็ได้คำตอบ"
ทีนี้เราสั่งให้ AI เพิ่มฟีเจอร์ "เพิ่มกลุ่ม" ซึ่งต้องรายงาน error แบบใหม่ว่ากลุ่มไม่ถูกต้อง สิ่งที่ควรทำคือเพิ่มช่องใหม่ InvalidGroupError เข้าไปในรายการ แต่สิ่งที่ AI ทำจริงมีอยู่ถึงห้ามุก ทุกมุกคอมไพล์ผ่านหมด ฝั่ง Haskell ผมยกมาให้ครบทุกมุก ส่วนฝั่ง Scala รวบไว้บล็อกเดียว (เขียนซ้อน else หลายอันเพื่อให้เห็นครบในที่เดียว ของจริงย่อมมีอันเดียว):
-- มุกที่ 1: ยัดข้อความลงช่อง UnknownUser
handleAddGroup :: AddGroupRequest -> IO (Either ErrorEvent Response)
handleAddGroup req
| validGroup group = -- ..
| otherwise = pure $ Left (UnknownUser $ "Invalid group: " <> group)
where
group = getGroup req
-- มุกที่ 2: ใช้เลขติดลบเป็น "รหัสพิเศษ"
handleAddGroup :: AddGroupRequest -> IO (Either ErrorEvent Response)
handleAddGroup req
-- ...
| otherwise = pure $ Left (DatabaseErrorCode (-1))
-- มุกที่ 3: ประกอบ JSON ปลอมขึ้นมาแล้วบอกว่า "JSON เสีย"
handleAddGroup :: AddGroupRequest -> IO (Either ErrorEvent Response)
handleAddGroup req
-- ...
| otherwise = pure $ Left $ InvalidJSON $
A.object ["errorType" .= "Invalid group", "group" .= group]
where
group = getGroup req
-- มุกที่ 4: ห่อเป็น exception แล้วบอกว่า "เน็ตล่ม"
handleAddGroup :: AddGroupRequest -> IO (Either ErrorEvent Response)
handleAddGroup req
-- ...
| otherwise = pure $ Left $ NetworkError $
toException (userError $ "Invalid group: " <> show group)
where
group = getGroup req
-- มุกที่ 5: บอกว่า "ถูกยกเลิก" โดยไม่ระบุเหตุผลเลย
handleAddGroup :: AddGroupRequest -> IO (Either ErrorEvent Response)
handleAddGroup req
-- ...
| otherwise = pure $ Left (Canceled Nothing) import cats.syntax.all.*
def handleAddGroup(req: AddGroupRequest): IO[Either[ErrorEvent, Response]] =
val group = req.group
if validGroup(group) then ???
// มุกที่ 1: ยัดข้อความลงช่อง "ไม่รู้จักผู้ใช้"
else IO.pure(ErrorEvent.UnknownUser(s"Invalid group: $group").asLeft)
// มุกที่ 2: เลขติดลบเป็นรหัสพิเศษ
else IO.pure(ErrorEvent.DatabaseErrorCode(-1).asLeft)
// มุกที่ 3: ประกอบ JSON ปลอมแล้วบอกว่า JSON เสีย
else IO.pure(ErrorEvent.InvalidJson(
Json.obj("errorType" -> Json.fromString("Invalid group"),
"group" -> Json.fromString(group))
).asLeft)
// มุกที่ 4: ห่อเป็น exception แล้วบอกว่าเน็ตล่ม
else IO.pure(ErrorEvent.NetworkError(
new RuntimeException(s"Invalid group: $group")
).asLeft)
// มุกที่ 5: บอกว่าถูกยกเลิก โดยไม่ระบุเหตุผล
else IO.pure(ErrorEvent.Canceled(None).asLeft) import { Effect } from "effect"
// มุกที่ 1: ยัดข้อความลงช่อง UnknownUser
const handleAddGroup = (req: AddGroupRequest): Effect.Effect<Response, ErrorEvent> =>
validGroup(group)
? doAddGroup(group)
: Effect.fail(UnknownUser({ name: `Invalid group: ${group}` }))
// มุกที่ 2: เลขติดลบเป็นรหัสพิเศษ
Effect.fail(DatabaseErrorCode({ code: -1 }))
// มุกที่ 3: ประกอบ JSON ปลอมแล้วบอกว่า JSON เสีย
Effect.fail(InvalidJson({ value: { errorType: "Invalid group", group } }))
// มุกที่ 4: ห่อเป็น exception แล้วบอกว่าเน็ตล่ม
Effect.fail(NetworkError({ cause: new Error(`Invalid group: ${group}`) }))
// มุกที่ 5: บอกว่าถูกยกเลิก โดยไม่ระบุเหตุผล
Effect.fail(Canceled({ reason: null }))
มุกแรกมันเลือกช่อง UnknownUser ซึ่งแปลว่า "ไม่รู้จักผู้ใช้" แล้วยัดข้อความ "Invalid group: ..." ลงไปแทน คอมไพล์ผ่านฉลุย เพราะช่องนั้นรับข้อความอะไรก็ได้ แต่ตอนนี้โค้ดเราโกหกแล้ว ที่ไหนสักแห่งจะมี error "ไม่รู้จักผู้ใช้" โผล่มาทั้งที่ปัญหาจริงคือเรื่องกลุ่ม และ Justin ก็ไม่ได้เจอแค่มุกเดียว เขาไล่อีกสี่มุกที่เคยเจอมาจริง สรุปทั้งห้ามุก:
String อะไรก็ได้ ก็ยัดเรื่องที่ไม่เกี่ยวลงไป-1 ไม่ใช่รหัสฐานข้อมูลจริง มันคือรหัสที่เราแอบตกลงกันเองในใจCanceled Nothing หรือ Canceled(None) คือบอกว่าถูกยกเลิกโดยไม่บอกเหตุผล ข้อมูลหายเกลี้ยงString อะไรก็ได้ AI เลยยัดเรื่องที่ไม่เกี่ยวลงไป แทนที่จะเพิ่มช่องใหม่ (diagram วาดประกอบโดยผู้เขียน)ลองหยุดคิดก่อนสักครู่ ถ้าเป็นเรา จะปิดช่องโหว่นี้ยังไงให้ AI ยัดข้อความมั่วไม่ได้อีก พอมีคำตอบในใจแล้วค่อยกดดูวิธีที่ Justin เสนอ
ก่อนเข้าโค้ด ขอปูก่อนว่าทำไมเรื่องนี้ถึงสำคัญ เรกคอร์ด (record) คือ type ที่จับหลายช่องข้อมูลมามัดรวมเป็นก้อนเดียว เช่น "รายงาน" หนึ่งฉบับก็มีชื่อ มีผู้เขียน มีวันที่ อยู่ในก้อนเดียวกัน หัวใจของการออกแบบเรกคอร์ดให้ดีคือ "แต่ละช่องต้องเก็บของที่ตรงกับความหมายของมัน" ช่องผู้เขียนก็มีแต่ผู้เขียน ช่องวันที่ก็มีแต่วันที่ พอกติกานี้แน่น เวลาอ่านโค้ดเราถึงเชื่อสิ่งที่ช่องบอกได้ ท่าที่ 4 คือการทำลายกติกานี้เงียบ ๆ
มันเหมือนท่าที่ 3 แต่ย้ายจากช่องใน error type มาเป็นช่องข้อมูลในเรกคอร์ดแทน Justin ยกสองเรกคอร์ดนี้ ฝั่ง Haskell โชว์แค่หน้าตาเรกคอร์ด ส่วนท่าหลบผมยกเป็นฝั่ง Scala:
-- เรกคอร์ดรายงาน
data Report = Report
{ reportName :: String
, reportAuthors :: [String]
, reportDate :: Day
, ...
}
-- เรกคอร์ดเป้าหมาย
data Targets = Targets
{ fooTarget :: String
, barTarget :: String
, -- ...
} import java.time.LocalDate
// ที่มีอยู่เดิม
final case class Report(
name: String,
authors: List[String],
date: LocalDate,
)
// มุกที่ 1: ต้องเก็บ "หน่วยงานที่สังกัด" แต่ไม่มีช่อง จึงยัดลงลิสต์ผู้เขียน
Report(name, authors = List("ชิ", "affiliation: ฝ่ายวิจัย"), date = d)
// มุกที่ 2: "ยังไม่มีวันที่" จึงใส่วันที่ปลอมเป็นค่าเฝ้ายาม
Report(name, authors, date = LocalDate.EPOCH) // 1970-01-01
// มุกที่ 3: ต้องใช้ bazTarget แต่ต้องไปแก้ไลบรารีอีกตัว จึงยืม barTarget ไปก่อน
final case class Targets(fooTarget: String, barTarget: String)
config.copy(barTarget = bazPath) // คอมไพล์ผ่าน ความหมายผิด // ที่มีอยู่เดิม
interface Report {
readonly name: string
readonly authors: readonly string[]
readonly date: Date
}
// มุกที่ 1: ต้องเก็บหน่วยงานที่สังกัด แต่ไม่มีช่อง จึงยัดลงลิสต์ผู้เขียน
const r1: Report = { name, authors: ["ชิ", "affiliation: ฝ่ายวิจัย"], date: d }
// มุกที่ 2: "ยังไม่มีวันที่" จึงใส่วันที่ปลอมเป็นค่าเฝ้ายาม
const r2: Report = { name, authors, date: new Date(0) } // 1970-01-01
// มุกที่ 3: ต้องใช้ bazTarget แต่ต้องไปแก้ไลบรารีอีกตัว จึงยืม barTarget ไปก่อน
const cfg: Targets = { ...config, barTarget: bazPath } // คอมไพล์ผ่าน ความหมายผิด
ท่าหลบที่เกิดกับสองอันนี้มีสามมุก มุกแรกคือต้องเก็บหน่วยงานที่สังกัด แต่ไม่มีช่อง เลยยัดลงไปในลิสต์ reportAuthors ซะเลย มุกที่สองคือต้องบอกว่ายังไม่มีวันที่ แต่แทนที่จะเปลี่ยน reportDate เป็น Maybe Day ก็ใส่วันที่ปลอมอย่าง ModifiedJulianDay 0 แทน และมุกที่สามคือต้องใช้ bazTarget แต่การเพิ่มช่องนั้นต้องข้ามไปแก้ไลบรารีอีกตัว ก็เลยยืม barTarget ที่มีอยู่แล้วไปใช้ก่อน
ค่าปลอมแบบวันที่ 0 นี่มีชื่อเรียกว่า sentinel value ("เซนทิเนล แวลิว" แปลว่า "ค่าเฝ้ายาม") คือค่าปกติค่าหนึ่งที่เราแอบตกลงกันเองว่าให้แปลว่าไม่มีข้อมูล ปัญหาคือข้อตกลงนั้นอยู่ในหัวคนเท่านั้น ไม่ได้อยู่ในโค้ด วันหนึ่งมีคนเอาไปคำนวณอายุเอกสารตรง ๆ ก็จะได้ผลว่าเอกสารนี้อายุ 56 ปีมาแบบงง ๆ
Justin ชี้ว่าแรงจูงใจของท่านี้ชัดมาก การเพิ่มช่องใหม่ในเรกคอร์ดที่มีคนใช้อยู่หลายที่ แปลว่าต้องตามไปแก้ทุกจุดที่สร้างเรกคอร์ดนั้น และถ้ามันอยู่ข้ามขอบเขตไลบรารี ก็ยิ่งแพงเข้าไปอีก ยืมช่องเดิมแก้ที่เดียวจบง่ายกว่าเยอะ
ท่านี้ตัวอย่างเป็นเรื่องเขตแดนในอเมริกาเหนือ ขอปูความรู้ก่อนสักนิดจะได้ตามทัน Alaska, Arkansas, Arizona พวกนี้คือ "รัฐ" ของสหรัฐอเมริกา ส่วน Canada กับ Mexico เป็น "ประเทศ" คนละชั้นกัน (ประเทศใหญ่กว่ารัฐ) จุดที่ต้องจับตาคือพอโค้ดเอาประเทศกับรัฐมากองปนกันในระดับเดียวกัน ความจริงข้อนี้จะหายไป
สมมติเดิมเรามีแค่รัฐในอเมริกาอยู่แล้ว:
data State = Alaska | Arkansas | Arizona | ...
processState :: State -> IO () enum State:
case Alaska, Arkansas, Arizona
def processState(s: State): IO[Unit] type State = "Alaska" | "Arkansas" | "Arizona"
declare function processState(s: State): Effect.Effect<void> วันหนึ่งงานขยาย ต้องรองรับภูมิภาคที่มีแคนาดาและเม็กซิโกด้วย สิ่งที่ AI มักทำคือแบนทุกอย่างมากองรวมกันชั้นเดียว แล้วเลี่ยงการสร้าง type ใหม่:
data Region = Canada | Mexico | Alaska | Arkansas | Arizona
processRegion :: Region -> IO ()
processRegion = \case
Canada -> ...
Mexico -> ...
st -> processState st
processState :: Region -> IO ()
processState = \case
Canada -> pure ()
Mexico -> pure ()
Alaska -> ... -- actual logic // เวอร์ชันแบนราบที่ AI ชอบเขียน
enum Region:
case Canada, Mexico, Alaska, Arkansas, Arizona
def processRegion(r: Region): IO[Unit] = r match
case Region.Canada => ???
case Region.Mexico => ???
case st => processState(st)
def processState(r: Region): IO[Unit] = r match
case Region.Canada | Region.Mexico => IO.unit // เคสที่เป็นไปไม่ได้ แต่ต้องเขียนกันคอมไพเลอร์บ่น
case Region.Alaska => ??? // เวอร์ชันแบนราบที่ AI ชอบเขียน
type Region = "Canada" | "Mexico" | "Alaska" | "Arkansas" | "Arizona"
const processRegion = (r: Region): Effect.Effect<void> => {
switch (r) {
case "Canada": return todo()
case "Mexico": return todo()
default: return processState(r) // r ยังเป็น Region ไม่ใช่ State
}
}
const processState = (r: Region): Effect.Effect<void> => {
switch (r) {
case "Canada":
case "Mexico": return Effect.void // เคสที่เป็นไปไม่ได้ แต่ต้องเขียนกันคอมไพเลอร์บ่น
case "Alaska": return todo()
// ...
}
}
ดูให้ดีนะครับว่ามันแย่ตรงไหน processState ที่เดิมรับ State ตอนนี้ถูกเปลี่ยนให้รับ Region แทน แปลว่ามันต้องเขียนเคส Canada กับ Mexico ไว้ในฟังก์ชันที่ชื่อว่า "ประมวลผลรัฐ" ทั้งที่แคนาดากับเม็กซิโกเป็นประเทศ ไม่ใช่รัฐ แล้วมันก็กลบด้วย pure () ซึ่งแปลว่าเจอเคสนี้แล้วไม่ต้องทำอะไร เป็นการปิดปากคอมไพเลอร์ที่กำลังจะบอกเราว่าโครงสร้างนี้มันผิดนะ
ส่วนนี้ไม่ได้อยู่ในบทความต้นฉบับนะครับ ผมเติมเอง เพราะพอเขียน Scala สาย functional จริง ๆ แล้วท่าหลบพวกนี้มันโผล่ในอีกสองที่ที่เจ็บกว่าเดิม
ที่แรกคือใน stream fs2 ("เอฟพีเอสทู" ย่อจาก Functional Streams for Scala) คือไลบรารีสำหรับไหลข้อมูลทีละก้อนแบบไม่กินแรม พอ AI เจอว่าบางแถวพาร์สไม่ผ่าน ท่าที่มันชอบเลือกคือกลืน error ทิ้ง:
import fs2.Stream
import cats.effect.IO
// ท่าหลบ: แถวที่พังหายไปเงียบ ๆ ไม่มีใครรู้ว่าหายกี่แถว
def ingest(rows: Stream[IO, RawRow]): Stream[IO, Record] =
rows.evalMap(parse).attempt.collect { case Right(r) => r }
// ท่าหลบอีกแบบ: กลืน error แล้วปิด stream ดื้อ ๆ
def ingest2(rows: Stream[IO, RawRow]): Stream[IO, Record] =
rows.evalMap(parse).handleErrorWith(_ => Stream.empty) import { Stream, Effect } from "effect"
// ท่าหลบ: แถวที่พังหายไปเงียบ ๆ ไม่มีใครรู้ว่าหายกี่แถว
const ingest = (rows: Stream.Stream<RawRow>): Stream.Stream<Record> =>
rows.pipe(
Stream.mapEffect((row) => Effect.option(parse(row))),
Stream.filterMap((o) => o), // ทิ้งแถวที่พาร์สไม่ผ่านเงียบ ๆ
)
// ท่าหลบอีกแบบ: กลืน error แล้วปิด stream ดื้อ ๆ
const ingest2 = (rows: Stream.Stream<RawRow>): Stream.Stream<Record> =>
rows.pipe(Stream.mapEffect(parse), Stream.catchAll(() => Stream.empty)) .attempt.collect { case Right(r) => r } อ่านออกมาเป็นภาษาคนคือ "ลองทำดู ถ้าพังก็ทิ้งแถวนั้นไปเลย" งานรัน 10,000 แถวสำเร็จ 200 แถว แต่ไม่มี error สักตัว ไม่มีใครรู้ว่าหายไป 9,800 แถว นี่คือ pure () ในเวอร์ชันที่แพงกว่าเยอะ ทางที่ถูกคือให้ความล้มเหลวไหลอยู่ใน stream ในฐานะค่า แล้วค่อยแยกสองทางที่ปลาย
ที่สองคือในโปรโตคอลของ actor Pekko ("เพกโก") คือไลบรารี actor สำหรับระบบกระจายศูนย์ แยกสายมาจาก Akka หัวใจของมันคือ actor แต่ละตัวรับได้เฉพาะข้อความที่ประกาศไว้ ซึ่งก็คือ sum type ดี ๆ นี่เอง ท่าหลบทุกท่าข้างบนจึงใช้ได้หมด:
import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors
// โปรโตคอลของ actor ก็คือ sum type ดี ๆ นี่เอง ท่าหลบเดิมใช้ได้หมด
enum Command:
case AddGroup(group: GroupName, replyTo: ActorRef[Ack])
case RemoveGroup(group: GroupName, replyTo: ActorRef[Ack])
enum Ack:
case Ok
case Failed(reason: String) // ช่องโหว่: อะไรก็ยัดลงมาได้
def behavior: Behavior[Command] = Behaviors.receiveMessage:
case Command.AddGroup(g, replyTo) =>
// ท่าหลบ: ทุกความล้มเหลวกลายเป็นข้อความเดียวกันหมด
replyTo ! Ack.Failed(s"could not add $g")
Behaviors.same
case _ => Behaviors.same // และเคสที่ยังไม่อยากคิด ก็เงียบไปเฉย ๆ import { Data } from "effect"
// โปรโตคอลของ actor ก็คือ discriminated union ดี ๆ นี่เอง ท่าหลบเดิมใช้ได้หมด
type Command = Data.TaggedEnum<{
AddGroup: { readonly group: GroupName; readonly replyTo: Reply<Ack> }
RemoveGroup: { readonly group: GroupName; readonly replyTo: Reply<Ack> }
}>
type Ack = Data.TaggedEnum<{
Ok: {}
Failed: { readonly reason: string } // ช่องโหว่: อะไรก็ยัดลงมาได้
}>
const { Ok, Failed } = Data.taggedEnum<Ack>()
// ท่าหลบ: ทุกความล้มเหลวกลายเป็นข้อความเดียวกันหมด
const onAddGroup = (g: GroupName, replyTo: Reply<Ack>) =>
replyTo(Failed({ reason: `could not add ${g}` })) Failed(reason: String) คือประตูเปิดทิ้งไว้ให้ยัดอะไรก็ได้ พอเปลี่ยนเป็น Failed(reason: ErrorEvent) ปุ๊บ AI ก็ยัดข้อความมั่วไม่ได้อีก มันจะถูกบังคับให้มาคุยกับเราว่าขอเพิ่มชนิดของ error นะ ซึ่งคือสิ่งที่เราอยากได้ตั้งแต่แรก ส่วน case _ => Behaviors.same ท้ายบล็อกก็คือญาติสนิทของ pure () นั่นแหละครับ
Justin สรุปแรงจูงใจเบื้องหลังไว้สามข้อ ซึ่งผมว่าอ่านแล้วเข้าใจ AI ขึ้นเยอะ:
Justin อ่านพฤติกรรมพวกนี้ว่าเป็นสัญญาณของโมเดลที่ทำงานภายใต้ความกดดัน มันเลือกทางที่เปลืองแรงน้อยที่สุด ไม่ใช่ทางที่ถูกที่สุด
และเขาย้ำจุดต่างที่สำคัญมากระหว่างคนกับ AI เวลาคนเขียนโค้ดแล้วเบี่ยงจากแผน บ่อยครั้งมันเป็นเพราะเขาเพิ่งค้นพบความจริงบางอย่างของโดเมนงานระหว่างทาง ซึ่งมีค่ามาก แต่การเบี่ยงของ AI ไม่ได้มาจากการค้นพบแบบนั้น มันมาจากการเลือกทางที่ง่ายกว่าเฉย ๆ Justin ประเมินว่าโอกาสที่ท่าหลบด่านครั้งหนึ่ง ๆ จะเป็นการตัดสินใจที่ชอบธรรมจริง ๆ นั้นต่ำกว่า 1%
ข้อสรุปของเขาไม่ใช่ให้เลิกใช้ AI แต่คือเลิกหวังว่ามันจะเขียนถูกตั้งแต่แรก แล้วไปลงแรงกับการตั้งกับดักแทน:
ignore/@nowarn เพิ่มเข้ามาไหม อันนี้ตรวจด้วยเครื่องได้ง่ายที่สุดString/Int ดิบ ๆ ตามหลัก Parse, Don't Validate (ฝั่ง Scala คือ opaque type คู่กับ smart constructor)ข้อคิดที่เอามาใช้กับภาษาอื่นได้
ท่าทั้งห้านี้เจอได้ทุกที่ครับ ทั้ง # type: ignore ใน Python, as any ใน TypeScript, asInstanceOf ใน Scala, การยัด error ใหม่ลง exception เดิม, หรือการใช้ -1 แทนคำว่าไม่มีค่า ทั้งหมดคือสายพันธุ์เดียวกัน คือการเอาชนะเครื่องมือตรวจสอบ แทนที่จะเอาชนะปัญหา
ปิดท้าย Justin ฝากไอเดียไว้ว่าอยากให้มีคนทำ skill สำหรับ Claude Code ที่คอยรีวิว diff ของ Haskell โดยเฉพาะ ไล่จับทั้งการปิดคำเตือน การลดความเข้มของ type และการยัดข้อความหรือยืมช่อง และที่สำคัญที่สุดคือจับโค้ดที่ควรเปลี่ยนแต่ไม่เปลี่ยนให้ได้ เขาบอกว่ายังมีตอนต่อไปเรื่องการวางแผนงาน Haskell กับ AI ให้ดี และการดูแลโปรเจกต์ที่ปล่อยให้ AI เขียนไปเยอะแล้วอีกด้วย
Effect, Stream, Data.taggedEnum, Brand, Option, Either) เป็นโค้ดเชิงอธิบาย เน้นให้เห็นแพตเทิร์นเดียวกัน ไม่ใช่โค้ดที่คอมไพล์ได้ทันทีทั้งไฟล์