Skip to content
academia.sh

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 satisfy Storer, three do not.
  • The single-value form (v := value.(Type)) panics when it fails; the panic is only caught with recover and read as panic=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 false count (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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close