Skip to content
beginner Phase 2 · Types, Operators, and Control Flow

Explicit Type Conversions

Convert between numeric types, strings, bytes, and runes without implicit coercion.

35m
0 problems
Topic Progress 0%

The Assignment That Refuses to Compile

You write the most natural line in programming:

var i int = 42
var f float64 = i      // compile error: cannot use i (variable of type int) as type float64

Go's rule, straight from the spec: explicit conversions are required when different numeric types are mixed in an expression or assignment. There is no implicit widening, no silent narrowing, no promotion to a common type. The compiler's position is that every change of representation should be visible in the source, because the ones that are not visible are the ones that lose data unnoticed.

f := float64(i)              // int → float64: exact for small values
var j int = int(3.9)         // float → int: fraction discarded, truncating toward zero → 3
k := int(math.Round(3.9))    // rounding is a library call, never the default

Go truncates toward zero for every float-to-int conversion — int(-3.9) is -3, not -4 — and rounding requires you to say so with math.Round first. Precision flows the other way too: converting a large int64 to float32 silently rounds beyond 2^24 because float32 has 24 significand bits, and a non-constant conversion whose value the destination cannot represent succeeds anyway with implementation-dependent results. If the destination is an integer type, the value is simply out of range. That asymmetry is why exact arithmetic over money never uses floats: keep cents in int64, convert only at the edge where you format for display.

Integer-to-integer conversions follow a mechanical rule: sign-extend (signed) or zero-extend (unsigned) to infinite precision, then truncate to the destination's width. There is no overflow indication — the bits you did not fit are simply gone:

var v uint16 = 0x10F0
fmt.Printf("%x\n", uint32(int8(v)))   // fffffff0 — sign extension, then truncation

var neg int64 = -1
big := uint64(neg)                     // 18446744073709551615 — same bits, new interpretation

The second example is the classic bug generator: a negative count converted to an unsigned type becomes enormous, loops run essentially forever, and nothing warned you. Conversions between integer sizes and between signedness are always legal and never checked — checking is the code's job. The safe habit: convert at the moment of use, close to the comparison or call that needs the type, rather than converting values early and carrying reinterpretations around. The same discipline, applied to text, is where the conversions get genuinely interesting.

One String, Three Underlays: bytes, runes, and digits

A string in Go is an immutable sequence of bytes — usually UTF-8 encoded — and the conversions to and from its neighbors each answer a different question. Choosing the wrong one is the difference between "alice" and "A".

s := "héllo"

b := []byte(s)     // the encoded bytes: 6 bytes (é is two bytes)
r := []rune(s)     // the decoded code points: 5 runes

[]byte(s) copies the underlying bytes — a fresh slice, safe to mutate (see Value Semantics and Copies for why the copy matters). []rune(s) decodes the UTF-8 encoding into code points, which is what you want before indexing characters, slicing by grapheme, or measuring text in characters rather than bytes. The reverse direction re-encodes: string(r) rebuilds the UTF-8 bytes, and string(0x4E2D) produces "中" — a single rune encoded as its bytes.

That last line hides the sharpest trap in the language:

fmt.Println(string(65))          // "A" — 65 is treated as rune U+0041, NOT "65"
fmt.Println(strconv.Itoa(65))    // "65" — the digits, which is what you meant

Converting an integer to a string yields the string of one rune with that code point, and for int values vet's stringintconv analysis flags it precisely because string(n) almost always means strconv.Itoa(n) to the author. The rule of thumb: string(runeValue) builds text from code points; anything that should look like digits goes through strconv.Itoa or fmt.Sprintf. While you are in that package, strings, fmt, and strconv covers when to reach for each — with strconv.Atoi returning (int, error) rather than a plausible wrong number when the input is "12a3".

The reverse conversions carry the same discipline:

n, err := strconv.Atoi(countStr) // parse: string → int, error checked
label := strconv.Itoa(n)         // format: int → string, allocation-light

Parse errors are ordinary values, not exceptions — the err != nil shape you will meet in The error Interface and Error Messages — and they are the reason strconv beats fmt for machine-readable output: no %!d(string=x) surprises, one clear failure. Also remember len(s) counts bytes, so len("世界") is 6 while len([]rune("世界")) is 2; the full treatment of that mismatch lives in Iterating Strings, Bytes, and Runes.

Where Conversions End and Assertions Begin

Conversions have a hard boundary: the compiler must know, at compile time, that the conversion is meaningful. Everything on one side of that boundary is checked before your program runs; everything on the other is checked — if at all — while it runs.

Named types are on the conversion side, governed by one rule: a conversion is allowed when the two types have identical underlying types.

type Celsius float64
type Fahrenheit float64

var c Celsius = 21.5
var f float64 = float64(c)   // fine: same underlying type
var d Fahrenheit = Fahrenheit(f) // fine — but only this explicitly
// var x float64 = c          // compile error: Celsius is not float64

Note the assignability subtlety: Celsius and float64 share an underlying type, but both are named, so neither is assignable to the other without conversion — that is exactly the safety feature. Your temperature variable cannot drift into distance calculations by accident, and the compiler's complaint names the type you forgot to acknowledge. The same rule extends structurally: two struct types with identical field names, types, and order convert (struct tags are ignored for this check), while []int and type IDs []int convert because the named slice's underlying type is []int. But []int and type ID int-element slices do not convert — []int versus []ID have different element types, so no identical underlying type exists and you write a loop. Conversions never inspect runtime content; they assert a static fact about representation.

Interfaces are the other side of the boundary. You cannot convert any to string because the compiler does not know what the interface holds — that is knowledge only the runtime has, and Go marks the moment you demand it with an assertion:

var value any = "alice"

s := value.(string)        // compiles; panics at runtime if the dynamic type differs
s, ok := value.(string)    // comma-ok: ok == false instead of a panic
if n, ok := value.(int); ok {
	fmt.Println(n * 2)
}

An assertion's failure mode is a panic, not a compile error — the entire difference between the two halves of this topic. Use the unchecked form only when the dynamic type is guaranteed by the surrounding logic (a decoder you control); everywhere else, comma-ok. Conversions fail early and loudly at compile time; assertions fail late and conditionally at runtime. Knowing which tool you are reaching for tells you which failure you are about to get — and the type-switch form of the same idea, which dispatches on several possible dynamic types at once, is the subject of Type Assertions and Type Switches.

Quiz

1. You write `var cents int64 = 1999; var dollars float64 = cents`. What does the compiler say, and what is the fix?

Question 1 options

2. What does `int(-3.9)` evaluate to, and how do you get -4?

Question 2 options

3. A log line does `fmt.Println("code: " + string(status))` where status is an int with value 66. What appears, and what is the correct conversion?

Question 3 options

4. `type Meters float64; var m Meters = 5; var d float64 = m` fails to compile. Why, and what fixes it?

Question 4 options

Flashcards

Question

Why does `var f float64 = 3` compile while `var f float64 = someInt` does not?

Answer

3 is an untyped constant — an exact value that adopts float64 when it fits — while someInt is a typed int variable, and mixing different numeric types requires an explicit conversion (float64(someInt)). Untyped constants convert implicitly at assignment; typed values never do.

Question

What happens when a float converts to an int, and what happens when an out-of-range int converts to a narrower type?

Answer

Float→int discards the fraction, truncating toward zero (int(-3.9) == -3), and rounding requires math.Round first. Narrow integer conversions sign- or zero-extend then truncate to the destination width — the discarded bits vanish with no error, so uint32(int8(0x10F0)) gives 0xfffffff0 and converting -1 to uint64 gives the maximum uint64.

Question

When is `[]byte(s)` the right conversion and `[]rune(s)` the wrong one?

Answer

When you need the raw encoded bytes — hashing, writing to a network buffer, byte-level manipulation — where the result is a fresh mutable copy of s's storage. []rune(s) instead decodes UTF-8 into code points (correct for character-wise slicing or counting) and would allocate a decoded slice you do not need; len() on each answers bytes versus characters, respectively.

Question

What is the fundamental difference between `x.(string)` and `string(x)`?

Answer

string(x) is a conversion checked entirely at compile time — it either compiles or the program never runs — and changes representation. x.(string) is a type assertion on an interface value: it compiles for any x, and the dynamic type is checked at runtime, where a mismatch panics unless you use the comma-ok form (s, ok := x.(string)).

Question

Can you assign a value of a named type directly to its underlying type?

Answer

No — `type Meters float64` and float64 are both named, and assignability needs identical underlying types with at least one side unnamed. You must write float64(m) (or Meters(f)) explicitly. The conversion is always allowed because the underlying types are identical; the point is that the crossing is visible in the source.

Revision Notes

Key Takeaways

  • 1. Go never converts implicitly between typed values — every representation change is written into the source
  • 2. Truncation toward zero is the only float→int behavior; rounding is math.Round's job, and out-of-range conversions are unchecked
  • 3. string(int) means one rune, not digits — strconv.Itoa is the digit conversion, and vet flags the difference
  • 4. Named types convert freely across identical underlying types but are not assignable to them, which is what keeps Meters out of float math
  • 5. Conversions are compile-time facts; interface assertions are runtime checks with panic as the default failure — comma-ok is the safe form

Interview Tips

  • • "Why does Go forbid implicit numeric conversion?" — name the cost of the alternative (silent truncation and precision loss hiding in assignments) and give the concrete example: float64(int) at the write site instead of drift discovered in totals
  • • "How do you convert a string to an int safely?" — strconv.Atoi with err != nil handling; contrast fmt.Sscanf/fmt.Sprintf and explain that parse failure is data, so it must be a returned error
  • • "What does string(65) produce, and what's the bug?" — "A", because it converts to a rune; strconv.Itoa gives "65"; mention vet's stringintconv as the safety net
  • • "Difference between a conversion and a type assertion?" — compile-time checked representation change versus runtime checked dynamic type with panic on failure; give comma-ok as the production form

Cheat Sheet

Explicit Type Conversions

Conversion rules:

Conversion Syntax Behavior
int → float float64(i) may lose precision beyond 2^53 (float64) / 2^24 (float32)
float → int int(f) truncates toward zero; round with math.Round first
int → narrower int int8(u) sign/zero-extend then truncate — no overflow error
signed → unsigned (same width) uint64(n) bits reinterpreted; -1 becomes max value
string → bytes []byte(s) fresh mutable copy of the encoded bytes
string → runes []rune(s) decodes UTF-8 into code points
rune → string string(r) encodes one code point as UTF-8
int → string strconv.Itoa(n) NOT string(n) — that yields one rune (vet: stringintconv)
named ↔ underlying float64(c) legal whenever underlying types are identical

Assignability: both named (e.g. Celsius and float64) → conversion required; at least one unnamed → assignment allowed.

Assertion vs conversion:

s := v.(string)          // runtime check; panics on mismatch
s, ok := v.(string)      // runtime check; ok flags failure instead
s2 := string(v.(int))    // compile-time conversion AFTER a runtime assertion

Money rule: integers (cents) internally, convert to float only at display time.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading