ข้ามไปยังเนื้อหา

Type Assertions

เมื่อ value ถูกเก็บใน interface variable, Go เก็บสองสิ่งไว้ภายใน: pointer ไปยัง concrete type descriptor และ pointer ไปยัง concrete value type assertion ดึง concrete value กลับออกมา

var s Shape = Circle{Radius: 5}
c := s.(Circle) // asserts that s holds a Circle
fmt.Println(c.Radius)

ถ้า s ไม่ได้เก็บ Circle นี่จะ panic ตอน runtime ใช้ form นี้เฉพาะเมื่อคุณแน่ใจใน dynamic type — เช่น ทันทีหลัง type check

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 ไม่แน่นอน

เมื่อต้องการ 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 หนึ่งไปยังอีก interface ได้:

var w io.Writer = os.Stdout
if rc, ok := w.(io.ReadWriter); ok {
// os.Stdout implements io.ReadWriter
_ = rc
}

นี่คือวิธีที่โค้ด standard library ตรวจสอบ optional capabilities (เช่น ว่า Writer รองรับ Seek ด้วยหรือไม่)

nil interface — ไม่มี type ไม่มี value — ทำงานสม่ำเสมอภายใต้ assertion ทั้งสอง form แต่ทั้งสอง form ต่างกันชัดเจน:

var s Shape // nil interface — no type, no value
c, 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} false
s is nil
panic: interface conversion: main.Shape is nil, not main.Circle

comma-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

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 Circle
pointer assertion matched

method 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 switchhandle หลาย type ได้ชัดเจนverbose กว่า single assertion
reflect.TypeOfintrospect type ณ runtimeoverhead สูง, ใช้เฉพาะจำเป็น

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: timeout

err ในที่นี้ถูก 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 target
fmt.Println(errors.As(err, nf))
panic: errors: target must be a non-nil pointer

การใช้ 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 calling
src.WriteTo(dst). Otherwise, if dst implements ReaderFrom, the copy is
implemented 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

เมื่อโค้ดที่ generate มา type-assert ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:

  • single-value assertion (v := x.(T)) ถูกใช้กับ value ที่อาจไม่เก็บ T หรือเปล่า — โดยเฉพาะ value ที่มาจาก nil interface variable หรือแหล่งที่ไม่น่าเชื่อถือ? นั่นคือ panic ที่รอวันเกิด
  • errors.As call ส่ง 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:)

v, ok := x.(T) ทำอะไรเมื่อ x ไม่ได้เก็บ value ของ type T?
เกิดอะไรขึ้น?
var s Shape // nil interface
c, ok := s.(Circle)
fmt.Println(c, ok)
_ = s.(Circle)
โค้ดนี้ compile ผ่านไหม และถ้าผ่าน ok เป็นอะไร?
type Shape interface{ Area() float64 }
type Circle struct{ Radius float64 }
func (c *Circle) Area() float64 { return 3.14 * c.Radius * c.Radius }

var s Shape = &Circle{Radius: 2}
_, ok := s.(Circle)
จะเกิดอะไรขึ้นตอนรัน?
var nf NotFoundError // not a pointer
errors.As(err, nf)
struct หนึ่ง embed http.ResponseWriter เพื่อเพิ่ม logging และ concrete writer ข้างใต้รองรับ http.Flusher wrapper นี้รองรับ http.Flusher ด้วยไหม?
%T ใน format string พิมพ์อะไร?