Type Assertions
Interface values ตอน runtime
หัวข้อที่มีชื่อว่า “Interface values ตอน runtime”เมื่อ value ถูกเก็บใน interface variable, Go เก็บสองสิ่งไว้ภายใน: pointer ไปยัง concrete type descriptor และ pointer ไปยัง concrete value type assertion ดึง concrete value กลับออกมา
Single-value form
หัวข้อที่มีชื่อว่า “Single-value form”var s Shape = Circle{Radius: 5}c := s.(Circle) // asserts that s holds a Circlefmt.Println(c.Radius)ถ้า s ไม่ได้เก็บ Circle นี่จะ panic ตอน runtime ใช้ form นี้เฉพาะเมื่อคุณแน่ใจใน dynamic type — เช่น ทันทีหลัง type check
Comma-ok form
หัวข้อที่มีชื่อว่า “Comma-ok form”variant ที่ปลอดภัยคืน boolean แทนการ panic:
c, ok := s.(Circle)if ok { fmt.Println("it's a circle with radius", c.Radius)} else { fmt.Println("not a circle")}ok เป็น true เมื่อ assertion สำเร็จ, false มิเช่นนั้น c เป็น zero value ของ Circle เมื่อ ok เป็น false ควรใช้ comma-ok form เสมอเมื่อ dynamic type ไม่แน่นอน
Type switch
หัวข้อที่มีชื่อว่า “Type switch”เมื่อต้องการ branch ตาม types หลายแบบ type switch สะอาดกว่าการเชื่อม if ok หลาย ๆ อัน:
switch v := x.(type) {case Circle: fmt.Println("circle", v.Radius)case Rect: fmt.Println("rect", v.W, v.H)default: fmt.Printf("unknown: %T\n", v)}ภายใน case แต่ละอัน v เป็น concrete type แล้ว — ไม่ต้อง assertion เพิ่มเติม default case ทำงานเมื่อไม่มี type ที่ระบุตรงกัน
case หนึ่งยังลิสต์หลาย types ร่วมกันได้ case Circle, Rect: — แต่แบบนั้น v จะคง interface type เดิม ไม่ใช่ concrete type เพราะ compiler เลือก concrete type เดียวให้ branch นั้นไม่ได้ มีแต่ single-type case เท่านั้นที่ narrow v
Assert ไปยัง interface
หัวข้อที่มีชื่อว่า “Assert ไปยัง interface”คุณยัง assert จาก interface หนึ่งไปยังอีก interface ได้:
var w io.Writer = os.Stdoutif rc, ok := w.(io.ReadWriter); ok { // os.Stdout implements io.ReadWriter _ = rc}นี่คือวิธีที่โค้ด standard library ตรวจสอบ optional capabilities (เช่น ว่า Writer รองรับ Seek ด้วยหรือไม่)
Assert บน nil interface
หัวข้อที่มีชื่อว่า “Assert บน nil interface”nil interface — ไม่มี type ไม่มี value — ทำงานสม่ำเสมอภายใต้ assertion ทั้งสอง form แต่ทั้งสอง form ต่างกันชัดเจน:
var s Shape // nil interface — no type, no valuec, ok := s.(Circle)fmt.Println(c, ok) // {0} false — comma-ok is safe on a nil interface
switch v := s.(type) {case nil: fmt.Println("s is nil")case Circle: fmt.Println("circle", v)}
_ = s.(Circle) // single-value form panics on a nil interfaceรันบน Go 1.26.5:
{0} falses is nilpanic: interface conversion: main.Shape is nil, not main.Circlecomma-ok form ปลอดภัยสมบูรณ์ — ok เป็น false เฉยๆ type switch จับ nil case ได้ชัดเจนด้วย case nil: ซึ่งทำงานก่อนที่ concrete-type case ใด ๆ จะถูกพิจารณา มีแค่ single-value form เท่านั้นที่ panic และข้อความระบุ nil interface อย่างเจาะจง: main.Shape is nil, not main.Circle เทียบกับการ assert concrete type ผิด บน interface ที่ไม่ nil — ใช้ single-value form เหมือนกัน แต่ข้อความต่างกัน:
var a Animal = Dog{}_ = a.(Cat)panic: interface conversion: main.Animal is main.Dog, not main.Catทั้งคู่เป็น interface conversion panic แต่อันแรกระบุ nil ในขณะที่อันหลังระบุ dynamic type จริง — ควรจำให้ได้ทันทีเมื่อเห็นใน crash log
Pointer vs value: s.(*T) vs s.(T)
หัวข้อที่มีชื่อว่า “Pointer vs value: s.(*T) vs s.(T)”concrete type ไหนที่ interface variable เก็บได้ — T หรือ *T — ขึ้นกับ receiver kind ไหนที่ตอบสนอง interface และมันเปลี่ยนว่า type assertion จะสำเร็จกับอะไรได้บ้าง ถ้า Area มี receiver แบบ pointer Circle เองไม่เคยตอบสนอง Shape เลย ดังนั้นการ assert ไปยัง value type ไม่ใช่แค่ false ตอน runtime — มัน compile ไม่ผ่านด้วยซ้ำ:
type Shape interface{ Area() float64 }type Circle struct{ Radius float64 }
func (c *Circle) Area() float64 { return 3.14 * c.Radius * c.Radius }
func main() { var s Shape = &Circle{Radius: 2} if _, ok := s.(Circle); ok { // Circle does not implement Shape at all // unreachable }}./main.go:13:14: impossible type assertion: s.(Circle) Circle does not implement Shape (method Area has pointer receiver)compiler พิสูจน์เรื่องนี้แบบ static เพราะ method set ของ Circle ไม่เคยมี Area เลย — assert ไปยัง type ที่ไม่มีทางตอบสนอง interface ได้เป็น compile error ไม่ใช่ runtime maybe เปลี่ยนให้ Area มี receiver แบบ value แทน แล้วทั้ง Circle และ *Circle ตอบสนอง Shape ได้ทั้งคู่ — แต่ s ยังเก็บแค่ concrete type ที่มัน assign มาเท่านั้น ดังนั้น value assertion จึง fail ตอน runtime ในขณะที่ pointer assertion สำเร็จ:
func (c Circle) Area() float64 { return 3.14 * c.Radius * c.Radius } // value receiver
var s Shape = &Circle{Radius: 2} // *Circle satisfies Shape too (method set of *T includes value-receiver methods)
if _, ok := s.(Circle); ok { fmt.Println("value assertion matched")} else { fmt.Println("value assertion failed — s holds *Circle, not Circle")}if _, ok := s.(*Circle); ok { fmt.Println("pointer assertion matched")}value assertion failed — s holds *Circle, not Circlepointer assertion matchedmethod set ที่ใหญ่กว่าของ *T ทำให้ *Circle ตอบสนอง interface ที่ Circle เดี่ยว ๆ ก็ตอบสนองได้เหมือนกัน — แต่ assertion ยังเช็ก dynamic type ที่เก็บอยู่จริง ไม่ใช่ “type ใดก็ได้ที่ method set ผ่านเกณฑ์”
package main
import "fmt"
type Animal interface { Sound() string}
type Dog struct{ Name string }type Cat struct{ Name string }
func (d Dog) Sound() string { return "Woof" }func (c Cat) Sound() string { return "Meow" }
func identify(a Animal) { // comma-ok form — safe even when a is nil if d, ok := a.(Dog); ok { fmt.Printf("Dog named %s says %s\n", d.Name, d.Sound()) return } // type switch handles the nil interface explicitly switch v := a.(type) { case nil: fmt.Println("no animal") case Cat: fmt.Printf("Cat named %s says %s\n", v.Name, v.Sound()) default: fmt.Printf("Unknown animal: %T\n", v) }}
func main() { animals := []Animal{Dog{Name: "Rex"}, Cat{Name: "Luna"}, nil} for _, a := range animals { identify(a) }} ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| สิ่งที่ได้ | ประโยชน์ | ต้นทุน |
|---|---|---|
| type assertion v, ok := i.(T) | ปลอดภัย ไม่ panic | ต้องตรวจสอบ ok ทุกครั้ง |
| type assertion v := i.(T) | กระชับ | panic ถ้า dynamic type ไม่ตรง |
| type switch | handle หลาย type ได้ชัดเจน | verbose กว่า single assertion |
| reflect.TypeOf | introspect type ณ runtime | overhead สูง, ใช้เฉพาะจำเป็น |
errors.As คือ assertion ที่เดิน chain
หัวข้อที่มีชื่อว่า “errors.As คือ assertion ที่เดิน chain”errors.As ดูเหมือน function call แต่โดยแนวคิดแล้วมันคือ type assertion ที่ unwrap ด้วย: มันเรียก Unwrap() บน error chain ซ้ำ ๆ, type-assert แต่ละ link เทียบกับ target type จนกว่าจะเจอที่ตรงหรือ chain จบ
type NotFoundError struct{ Key string }
func (e *NotFoundError) Error() string { return "not found: " + e.Key }
func lookup(key string) error { return &NotFoundError{Key: key}}
func loadConfig() error { if err := lookup("timeout"); err != nil { return fmt.Errorf("load config: %w", err) } return nil}
func main() { err := fmt.Errorf("startup: %w", loadConfig())
var nf *NotFoundError if errors.As(err, &nf) { // walks the %w chain until it finds a *NotFoundError fmt.Println("missing key:", nf.Key) }}missing key: timeouterr ในที่นี้ถูก wrap สองชั้น (startup: แล้ว load config: ) แต่ errors.As เจอ *NotFoundError ที่ลึกลงไปสองชั้น — สิ่งที่ single err.(*NotFoundError) assertion ทำไม่ได้เลย เพราะมันเห็นแค่ error ชั้นนอกสุดเท่านั้น argument target ต้องเป็น non-nil pointer ไปยัง type ที่ implement error (หรือไปยัง interface type) การส่ง error value เองแทนที่จะเป็น pointer ไปยังตัวแปรของ type นั้น จะ panic:
err := &NotFoundError{Key: "x"}var nf NotFoundError // not a pointer — wrong targetfmt.Println(errors.As(err, nf))panic: errors: target must be a non-nil pointerเมื่อ type assertion เป็น design smell — และเมื่อไม่ใช่
หัวข้อที่มีชื่อว่า “เมื่อ type assertion เป็น design smell — และเมื่อไม่ใช่”การใช้ type assertion เพื่อดึง concrete type กลับมามักหมายความว่า interface เป็นเครื่องมือที่ผิด หรือกว้างเกินไป — เป็น smell ที่ควรตั้งคำถามก่อนเขียน if v, ok := x.(ConcreteType); ok แต่ standard library ก็ใช้ assertion อย่างตั้งใจเหมือนกัน เป็น optional capability check ไม่ใช่ทางลัด control-flow go doc io.Copy ระบุ contract ไว้ชัดเจน:
If src implements WriterTo, the copy is implemented by callingsrc.WriteTo(dst). Otherwise, if dst implements ReaderFrom, the copy isimplemented by calling dst.ReadFrom(src).io.Copy รับ interface เล็ก Reader/Writer แต่ภายในมัน assert หา io.WriterTo และ io.ReaderFrom เพื่อไปทาง fast path เมื่อ concrete type รองรับ — caller ไม่ต้องรู้เลยว่าสิ่งนี้เกิดขึ้น
net/http ใช้ pattern เดียวกันกับ http.Flusher และ http.Hijacker และ doc comment ของ Flusher เองก็เตือนกับดักนี้ตรง ๆ: “ResponseWriter wrappers may not [support Flusher]. Handlers should always test for this ability at runtime.” wrapper สำหรับ logging ที่ embed http.ResponseWriter แสดงให้เห็นเป๊ะ ๆ ว่า capability หายไปได้อย่างไร:
type loggingWriter struct { http.ResponseWriter status int}
func (w *loggingWriter) WriteHeader(code int) { w.status = code w.ResponseWriter.WriteHeader(code)}
var _ http.Flusher = &loggingWriter{}./main.go:15:22: cannot use &loggingWriter{} (value of type *loggingWriter) as http.Flusher value in variable declaration: *loggingWriter does not implement http.Flusher (missing method Flush)การ embed http.ResponseWriter promote แค่สาม methods ที่ interface นั้นประกาศ (Header, Write, WriteHeader) — Flush และ Hijack ไม่ได้เป็นส่วนหนึ่งของมัน จึงไม่ถูก promote เลย แม้ concrete *http.response ข้างใต้จะรองรับก็ตาม การส่ง lw ให้โค้ดที่คาดหวัง streaming จะพังไปอย่างเงียบ ๆ ทางแก้คือ assert กับ embedded field ไม่ใช่ตัว wrapper แล้ว forward ไปตรง ๆ:
if f, ok := lw.ResponseWriter.(http.Flusher); ok { f.Flush()}ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- type assertion เสมอปลอดภัย — ไม่ใช้ comma-ok form จะ panic ทันทีถ้า type ไม่ตรง
- type switch เหมือน switch ทั่วไป — type switch ตรวจ dynamic type ของ interface ไม่ใช่ value
- type assertion = type conversion — conversion เปลี่ยน type representation, assertion ตรวจว่า interface เก็บ concrete type นั้นจริงหรือไม่
💡 ตัวอย่างจากของจริง
encoding/json: Unmarshal ใช้ type assertion เพื่อ handle
[]anyและmap[string]anyเมื่อ JSON structure ไม่รู้ล่วงหน้าnet/http: middleware ตรวจว่า ResponseWriter implement http.Flusher หรือ http.Hijacker ด้วย type assertion เพื่อ enable streaming หรือ WebSocket upgrade
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ generate มา type-assert ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
- single-value assertion (
v := x.(T)) ถูกใช้กับ value ที่อาจไม่เก็บTหรือเปล่า — โดยเฉพาะ value ที่มาจาก nil interface variable หรือแหล่งที่ไม่น่าเชื่อถือ? นั่นคือ panic ที่รอวันเกิด errors.Ascall ส่ง error value เองหรือตัวแปรที่ไม่ใช่ pointer เป็น argument ที่สองหรือเปล่า? มันต้องเป็น non-nil pointer ไปยัง target type ไม่งั้น panic- struct wrapper embed interface (
http.ResponseWriter,io.Writer, …) แล้วถูกส่งให้โค้ดที่คาดหวัง optional capability (Flusher,Hijacker,WriterTo) หรือเปล่า? การ embed promote แค่ methods ของ embedded interface เอง — capability เพิ่มเติมของ concrete value ข้างใต้หายไปอย่างเงียบ ๆ เว้นแต่ wrapper จะ assert แล้ว forward มันเอง - มี type assertion ดึง concrete type กลับจาก interface parameter ทันทีหลัง function รับ parameter นั้นเป็น interface หรือเปล่า? ปกติแปลว่า parameter ควรเป็น concrete type ตั้งแต่แรก หรือ interface กว้างเกินไป
- type switch มี
case nil:เมื่อ input อาจเป็น nil interface ได้จริงหรือเปล่า? ถ้าไม่มีcase ConcreteType:จะไม่ match nil เลย แล้วตกไปที่defaultอย่างเงียบ ๆ
🐛 หา bug ในโค้ดนี้
type Shape interface{ Area() float64 }type Circle struct{ Radius float64 }
func (c Circle) Area() float64 { return 3.14 * c.Radius * c.Radius }
func report(s Shape) { c := s.(Circle) fmt.Println(c.Area())}
func main() { var s Shape report(s)}เฉลย
report ใช้ single-value form s.(Circle) โดยไม่มี comma-ok check main เรียกมันด้วย s ที่ยังเป็น zero value อยู่ — nil interface ไม่ใช่ Circle assertion panic ทันที: panic: interface conversion: main.Shape is nil, not main.Circle ทางแก้คือ comma-ok form: c, ok := s.(Circle); if !ok { return } (หรือ handle nil case ชัดเจนใน type switch ด้วย case nil:)