Lesson 03 / 13
Type Assertion and Type Switch
Two of five values satisfy an interface; the type assertion's two-value form counts this without panicking, the single-value form panics when it fails, and the panic is only read as panic=true. A type switch distributes the same five values into its own branches. Limiting measurement: the same value can fit both a concrete-type branch and an interface branch, and the branch chosen depends on writing order — the lesson runs this with two orderings.
Published Updated
In this lesson
- Counts, with a type assertion's two-value form, how many of five values satisfy an interface without producing a panic
- Runs proof that a single-value type assertion panics when it fails while the two-value form returns an answer instead
- Measures how a type switch selects concrete-type branches and interface branches over the same five values
- Proves with two orderings that the same value can fit both a concrete-type branch and an interface branch, and that the branch chosen depends on writing order
Contents
The previous lesson measured that the empty interface carries a value’s type information
without losing it, but it only looked at that information with %T, for reading purposes.
The real use of that information is not to read it but to ask it: does this value satisfy this
interface, and if it does, give me the real value. Go asks this question with two separate
constructs — a type assertion asks about a single candidate, a type switch picks among
several candidates. Both ask the value, both look inside the same pair (type, value).
What both are asking is the most repeated pattern among the sixteen bindings in the first lesson: if a binding is not written in the source and the compiler does not establish it at the point of use, only one path remains — asking the value itself at runtime. This lesson measures the two forms of that asking, their results, and the risk one of them carries.
The Type Assertion’s Two-Value Form
A type assertion is written as value.(Type) and has two forms. The single-value form
(v := value.(Type)) returns only the value; if the assertion is wrong, it produces a runtime
error. The two-value form (v, ok := value.(Type)) returns a second boolean value; if the
assertion is wrong, ok becomes false and the program does not stop.
What is written in place of Type can ask two separate questions. When a concrete type is
written (“is this value’s dynamic type exactly this”), the comparison looks at the dynamic
type itself. When an interface is written (“does this value satisfy this contract”), the
comparison looks at the method set. Both share the same value.(Type) syntax; which question
is being asked is understood only from Type’s own nature — whether it is concrete or an
interface. The following measurement asks an interface question: it tests five separate values
against the same interface and counts how many satisfy it.
// five-questions.go — how many of five values satisfy the Storer interface
package main
import "fmt"
type Storer interface{ Store() string }
type DiskRecord struct{}
func (DiskRecord) Store() string { return "disk" }
type MemoryRecord struct{}
func (MemoryRecord) Store() string { return "memory" }
type Temporary struct{}
func main() {
values := []any{DiskRecord{}, 42, MemoryRecord{}, "text", Temporary{}}
trueCount := 0
for i, v := range values {
store, ok := v.(Storer)
if ok {
fmt.Printf("value#%d dynamic_type=%T Storer_ok=%t call=%s\n", i, v, ok, store.Store())
trueCount++
continue
}
fmt.Printf("value#%d dynamic_type=%T Storer_ok=%t\n", i, v, ok)
}
fmt.Println("true count out of five questions:", trueCount)
}
value#0 dynamic_type=main.DiskRecord Storer_ok=true call=disk value#1 dynamic_type=int Storer_ok=false value#2 dynamic_type=main.MemoryRecord Storer_ok=true call=memory value#3 dynamic_type=string Storer_ok=false value#4 dynamic_type=main.Temporary Storer_ok=false true count out of five questions: 2
Two of five values (DiskRecord, MemoryRecord) satisfy Storer, three (int, string,
Temporary) do not. Temporary is a struct and still does not satisfy it, because it never
wrote the Store method — the condition for interface satisfaction is not being a struct, it
is the method set matching. The ok variable gives a definite answer to each of these five
questions, and the loop never stops at any point; this is the two-value form’s real strength.
The store variable carries the second half of this measurement. A type assertion does not
only answer “does it satisfy” — when ok is true, store becomes a real value whose static
type is now Storer, and its Store method is callable. The call=disk and call=memory
columns show that this value is genuinely usable — the “cannot be called directly” cost from
the previous lesson is fully repaid here: once a type assertion succeeds, the value reopens the
access the empty interface had closed off.
IN7. Five values were deliberately chosen from three separate failure reasons: int and
string carry no method at all, Temporary is a struct but has the wrong (empty) method set.
All three give the same false answer but for different reasons — the measurement does not
tell this difference apart, it only counts whether each satisfies or not.
When the two-value form fails, the first variable (store) is not something random, it is the
target type’s own zero value — since Storer is an interface here, its zero value is nil.
This confirms once more a point tied together by the previous two lessons: in Go, what stands
in for something unwritten or failed is never left undefined, the zero value of its own type
always steps in.
When the Single-Value Form Fails: Panic
The two-value form turns a failure into a value; the single-value form turns the same failure into a panic. The following measurement runs this directly.
// panic-measurement.go — a single-value type assertion panics when it fails
package main
import "fmt"
type Storer interface{ Store() string }
type DiskRecord struct{}
func (DiskRecord) Store() string { return "disk" }
func panics(f func()) (p bool) {
defer func() {
if recover() != nil {
p = true
}
}()
f()
return false
}
func main() {
var successful any = DiskRecord{}
var unsuccessful any = 42
fmt.Println("does a successful single-value assertion panic:", panics(func() {
_ = successful.(Storer)
}))
fmt.Println("does an unsuccessful single-value assertion panic:", panics(func() {
_ = unsuccessful.(Storer)
}))
fmt.Println("does an unsuccessful two-value assertion panic:", panics(func() {
_, ok := unsuccessful.(Storer)
_ = ok
}))
}
does a successful single-value assertion panic: false does an unsuccessful single-value assertion panic: true does an unsuccessful two-value assertion panic: false
All three lines test the same unsuccessful value (an int); only the call form changes. The
single-value form does not panic for DiskRecord{}, because the assertion really is correct.
The same form panics for 42, because int does not satisfy Storer and the single-value
form throws a runtime error in this case — panics catches this error with recover and reads
it only as panic=true, no stack trace is ever printed. The third line tries the same failing
assertion with the two-value form, and no panic happens at all; ok quietly becomes false.
The difference is in the form: the same question, two separate result contracts.
IN8. panics is rebuilt here with the same function from the Go Fundamentals course’s
unwritten.go frame: it runs a function and catches with recover whether it panicked,
returning only one of two values. This is the single method used everywhere in the course that
measures a panic.
This two-value example alone is not enough: if all five values are run through the
single-value form, the number that panics should come out equal to the false count from the
earlier section — the two are reading the same fact from two separate contracts.
// panic-count.go — how many of five values panic in the single-value form
package main
import "fmt"
type Storer interface{ Store() string }
type DiskRecord struct{}
func (DiskRecord) Store() string { return "disk" }
type MemoryRecord struct{}
func (MemoryRecord) Store() string { return "memory" }
type Temporary struct{}
func panics(f func()) (p bool) {
defer func() {
if recover() != nil {
p = true
}
}()
f()
return false
}
func main() {
values := []any{DiskRecord{}, 42, MemoryRecord{}, "text", Temporary{}}
panicCount := 0
for i, v := range values {
panicked := panics(func() {
_ = v.(Storer)
})
fmt.Printf("value#%d dynamic_type=%T panicked=%t\n", i, v, panicked)
if panicked {
panicCount++
}
}
fmt.Println("panicking count out of five values:", panicCount)
}
value#0 dynamic_type=main.DiskRecord panicked=false value#1 dynamic_type=int panicked=true value#2 dynamic_type=main.MemoryRecord panicked=false value#3 dynamic_type=string panicked=true value#4 dynamic_type=main.Temporary panicked=true panicking count out of five values: 3
The panicking count is three, the exact complement of the earlier measurement’s trueCount
(two) — two of the five values satisfied it, three did not, and now those same three panic in
the single-value form. Two measurements ask the same five values, against the same interface,
with two separate call forms, and produce two numbers that add up to five. This is proof that
the two forms carry the same information: the difference is not in the information, it is in
how the failure is reported.
The Branch a Type Switch Selects
A type switch selects a single branch among several candidates. The switch v.(type) syntax
tries each case in order and continues from the first one that matches. The no-fall-through
rule measured in the Go Fundamentals course’s Control Flow lesson holds here too: the matching
branch does not fall into the next case, the branch ends on its own unless fallthrough is
written. The same five values now pass through a four-branch type switch.
// pick-branch.go — a type switch distributes five values into its own branches
package main
import "fmt"
type Storer interface{ Store() string }
type DiskRecord struct{}
func (DiskRecord) Store() string { return "disk" }
type MemoryRecord struct{}
func (MemoryRecord) Store() string { return "memory" }
type Temporary struct{}
func pickBranch(v any) string {
switch t := v.(type) {
case int:
return fmt.Sprintf("integer branch: %d", t)
case string:
return fmt.Sprintf("string branch: %q", t)
case DiskRecord:
return "DiskRecord branch"
case MemoryRecord:
return "MemoryRecord branch"
default:
return "default branch"
}
}
func main() {
values := []any{DiskRecord{}, 42, MemoryRecord{}, "text", Temporary{}}
for i, v := range values {
fmt.Printf("value#%d -> %s\n", i, pickBranch(v))
}
}
value#0 -> DiskRecord branch value#1 -> integer branch: 42 value#2 -> MemoryRecord branch value#3 -> string branch: "text" value#4 -> default branch
Four of the five values fall into a branch dedicated to their own concrete type; Temporary
matches no case and falls to default. The t in t := v.(type) is a variable whose static
type inside each branch is specific to that branch — in the case int branch t is an int,
in the case string branch a string. This is the type switch’s difference from a type
assertion: a type assertion asks about a single type, a type switch runs the same query against
several candidates at once and reports which one matched.
The same result could also be reached with five separate type assertions — writing an
if d, ok := v.(X); ok line for each candidate and picking the first true one. The type
switch gathers this into a single construct and automatically supplies t’s branch-specific
static type; with five separate assertions, this type would have to be rewritten by hand in
every branch. What is gained is not a new capability, it is the same query gathered in one
place.
Limiting Measurement: The Branch Chosen Depends on Writing Order
A type switch accepts not only concrete types but also interfaces as branches. If a value fits both a concrete-type branch and an interface branch, which one is chosen depends on the writing order of the branches — the first branch that matches wins.
The four branches in the previous section (int, string, DiskRecord, MemoryRecord)
excluded each other: a single value could fit only one of these branches. But a concrete type
and an interface it satisfies do not exclude each other — DiskRecord means both DiskRecord
itself and Storer. When two cases both claim the same value, a type switch does not raise a
conflict error, it silently picks whichever was written first.
// order-dependency.go — the same value gives two different results depending on branch order
package main
import "fmt"
type Storer interface{ Store() string }
type DiskRecord struct{}
func (DiskRecord) Store() string { return "disk" }
func classifyConcreteFirst(v any) string {
switch v.(type) {
case DiskRecord:
return "concrete: DiskRecord"
case Storer:
return "interface: Storer"
default:
return "unknown"
}
}
func classifyInterfaceFirst(v any) string {
switch v.(type) {
case Storer:
return "interface: Storer"
case DiskRecord:
return "concrete: DiskRecord"
default:
return "unknown"
}
}
func main() {
v := DiskRecord{}
fmt.Println("if the concrete branch is written first :", classifyConcreteFirst(v))
fmt.Println("if the interface branch is written first :", classifyInterfaceFirst(v))
}
if the concrete branch is written first : concrete: DiskRecord if the interface branch is written first : interface: Storer
DiskRecord{} fits both branches: its own concrete type and Storer, because it satisfies the
Store method. classifyConcreteFirst selects the DiskRecord branch because it is written
first; classifyInterfaceFirst drops the same value into the Storer branch, only because the
branches’ order was reversed. Both functions carry exactly the same two cases in their
source, only their order has changed; the result is entirely different. This looks at the same
fact the first lesson’s small-interface measurement did, from a different angle: a value can
satisfy more than one interface at the same time, and a type switch resolves that plurality by
selecting whichever branch was written first.
IN9. This order dependency holds not only between a concrete-type branch and an interface branch, but also between two separate interface branches — if a value satisfies two interfaces at once, whichever interface branch was written first still wins. The measurement here tests only a single pair (concrete type, interface) because this is the form most commonly encountered in real sources; the principle stays the same for more than two branches too.
The consequence of this contradicts the usual intuition about an ordinary switch: in a plain
switch, branch order generally only affects readability, not the result, because each branch
compares against a separate, mutually exclusive value. In a type switch, branches may not
exclude each other — a value can fit more than one branch at the same time — and in that case
order is no longer a mere readability preference, it is the decision that determines which
branch runs. What the measurement shows is exactly this: two functions carry the same two
cases, take the same value, and produce two separate answers purely because one line moved.
Summary
- A type assertion’s two-value form (
v, ok := value.(Type)) turns a failure into a boolean value; two of five values satisfyStorer, three do not. - The single-value form (
v := value.(Type)) panics when it fails; the panic is only caught withrecoverand read aspanic=true/panic=false. - A type switch runs the same query against several candidates at once and continues from the first matching branch, never falling into the next one.
- A value can fit both a concrete-type branch and an interface branch; which branch is chosen depends on the source’s writing order, not on the value itself.
- The two-value form producing no panic, and the single-value form’s panic count (three)
exactly matching the two-value form’s
falsecount (three), shows that both call forms carry the same information, only reporting failure differently. - Once a type assertion succeeds, the returned value is genuinely callable; the “cannot be called directly” cost from the previous lesson is repaid after a successful assertion.
Next Step
In this lesson, Storer wanted a single method and established a single contract. But when a
type has to satisfy more than one contract at the same time — able to both store and be closed
— a way is needed to combine these two contracts in the source. The Storer branch in the
limiting measurement was itself a hint of such a combination: DiskRecord only wrote Store
but could have satisfied other contracts too. The next lesson measures this combination: how do
embedded interfaces establish a contract in the source, and how does this combination happen
without adding a single new method to the implementation?
To keep your progress and take notes, Log in
My notes
Log in to take notes.