03 — Variables, Constants & Types
Variable Declaration — All Forms and When to Use Each
go
// ┌─────────────────────────────────────────────────────────────────┐
// │ Form │ Scope │ Use When │
// │ ─────────────── │ ──────────── │ ────────────────────────────── │
// │ var x int │ any │ need zero value, explicit type │
// │ var x = 42 │ any │ type inference at pkg scope │
// │ var x int = 42 │ any │ explicit type + initial value │
// │ x := 42 │ func body │ most common inside functions │
// │ var (...) │ any │ grouped package-level vars │
// └─────────────────────────────────────────────────────────────────┘
package server
// Package-level declarations — use `var` (:= not allowed here).
var (
shutdownChan = make(chan struct{}) // type inferred: chan struct{}
listenAddr = ":8080" // type inferred: string
maxProcs = runtime.GOMAXPROCS(0) // type inferred: int (runtime call)
)
func example() {
// Inside functions — `:=` is idiomatic.
port := 8080
host, err := lookupHost("example.com") // multi-value declaration
if err != nil {
return
}
// `:=` with mixed new + existing variables — legal if ≥1 is new.
host2, err := lookupHost("other.com") // host2 is new, err is reassigned
_ = host2
}
:= rules and the shadowing trap
go
func shadowTrap() int {
x := 5
if true {
x := 10 // ❌ ANTI-PATTERN: declares a NEW x in the if-block scope
// shadows the outer x — outer x is never modified
fmt.Println(x) // 10 (inner x)
}
fmt.Println(x) // 5 (outer x unchanged)
return x // returns 5
// ✅ CORRECT: use `=` to modify the outer variable:
// x = 10 // assignment, not declaration — modifies outer x
}
// ⚠️ The same trap in for-loops (pre-Go 1.22):
func loopShadowPre122() {
var fns []func()
for i := 0; i < 3; i++ {
fns = append(fns, func() { fmt.Println(i) }) // captures the SAME i
}
for _, f := range fns {
f() // pre-1.22: prints 3 3 3 (all see final i=3)
}
}
// ✅ Fixed — pass as parameter (pre-1.22 pattern):
func loopShadowFixed() {
var fns []func()
for i := 0; i < 3; i++ {
i := i // ⚠️ creates a new i per iteration (pre-1.22 idiom)
fns = append(fns, func() { fmt.Println(i) })
}
// Go 1.22+ fixes this — loop vars are per-iteration by default.
// The `i := i` shadow is no longer needed (but doesn't hurt).
}
Zero Values — The Memory Story
go
// Every Go variable is initialized to its type's zero value.
// No "uninitialized" memory ever exists — this is a safety guarantee.
// ┌───────────────────────────────────────────────────────────────┐
// │ Type │ Zero value │ Memory representation │
// │ ──────────── │ ──────────── │ ──────────────────────────── │
// │ int, uint │ 0 │ all zero bytes (e.g., 8 bytes)│
// │ float64 │ 0.0 │ IEEE 754 all-zero = 0.0 │
// │ bool │ false │ 0 byte │
// │ string │ "" │ {ptr=nil, len=0} (16 bytes) │
// │ pointer │ nil │ 0x0 (8 bytes) │
// │ slice │ nil │ {ptr=nil, len=0, cap=0} (24B)│
// │ map │ nil │ pointer to nil hmap (8 bytes) │
// │ chan │ nil │ pointer to nil hchan (8 bytes)│
// │ func │ nil │ pointer to nil (8 bytes) │
// │ interface │ nil │ {type=nil, val=nil} (16 bytes)│
// │ struct │ all-zero │ each field at its zero value │
// └───────────────────────────────────────────────────────────────┘
func zeroValueDemo() {
var s []int
fmt.Println(s == nil) // true — nil slice (ptr is nil, len 0, cap 0)
t := make([]int, 0) // non-nil empty slice (ptr to real array, len 0, cap 0)
fmt.Println(t == nil) // false
// Both have len=0 and range over them does nothing. BUT:
// json.Marshal(s) → "null" (nil slice)
// json.Marshal(t) → "[]" (empty slice)
// API consumers treat null and [] differently — be deliberate.
}
Zero-value struct initialization
go
type Server struct {
Addr string
Port int
TLS *tls.Config // nil = no TLS (zero value is meaningful)
Timeout time.Duration
}
func zeroValueStruct() {
// Zero-value initialization — all fields at their zero values.
// Design structs so the zero value is usable (Go idiom).
var s Server
// s.Addr = "", s.Port = 0, s.TLS = nil, s.Timeout = 0
// `0` for Port is probably wrong — use a constructor:
srv := NewServer(":8080")
_ = srv
}
// ✅ Idiom: make zero values useful, provide constructors for required fields.
func NewServer(addr string) *Server {
return &Server{
Addr: addr,
Port: 8080, // sensible default
Timeout: 30 * time.Second,
}
}
// ❌ ANTI-PATTERN: struct with required fields and no constructor.
// type Config struct { DSN string } // zero value has empty DSN → runtime panic
// Users will write `var cfg Config` and forget DSN.
Constants — Compile-Time Immutability
go
// Constants are evaluated at compile time. They CANNOT be:
// - mutated at runtime
// - of types that require runtime computation (slices, maps, structs)
// - declared with function calls (unless the function is constant)
const (
Pi = 3.14159265358979323846264338327950288 // arbitrary precision
MaxInt32 = 1 << 31 - 1 // compile-time arithmetic
DefaultPort = 8080
)
// Typed vs untyped:
const (
TimeoutSeconds int = 30 // typed: must convert to use as int64
TimeoutGeneric = 30 // untyped: adapts to any numeric context
)
func constantTypes() {
// Untyped constant adapts:
var i int = TimeoutGeneric // ok — becomes int
var i64 int64 = TimeoutGeneric // ok — becomes int64
var f float64 = TimeoutGeneric // ok — becomes float64
_ = i; _ = i64; _ = f
// Typed constant requires explicit conversion:
var i2 int64 = int64(TimeoutSeconds) // ok — explicit conversion
// var i3 int64 = TimeoutSeconds // ❌ compile error: int ≠ int64
_ = i2
}
Untyped constant precision — the power and the trap
go
// Untyped constants have ARBITRARY precision (they're big.Int at compile time).
// This lets you express values that don't fit in any runtime type:
const (
Big = 1 << 100 // 2^100 — way bigger than int64 (2^63-1)
Small = Big >> 99 // 2^1 = 2 — works because Big is untyped
)
func bigConstants() {
// The constant itself is fine (compile-time):
fmt.Println(Small) // 2
// But assigning to a runtime type fails if it doesn't fit:
// var x int = Big // ❌ compile error: constant overflows int
var x int = Small // ok — 2 fits in int
_ = x
// ⚠️ Float constant precision trap:
const Almost = 0.1 + 0.2 // 0.3 exactly (arbitrary precision at compile time)
var f float64 = 0.1 + 0.2 // 0.30000000000000004 (float64 arithmetic at runtime)
fmt.Println(Almost == 0.3) // true (compile-time, untyped)
fmt.Println(f == 0.3) // false (runtime float64)
}
iota — Beyond Simple Enums
go
// iota resets to 0 in each const block and increments per LINE (not per use).
// --- Basic enum ---
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
Wednesday // 3
Thursday // 4
Friday // 5
Saturday // 6
)
// --- Bit flags (permissions) ---
type Permission uint8
const (
Read Permission = 1 << iota // 1 (00000001)
Write // 2 (00000010)
Execute // 4 (00000100)
Delete // 8 (00001000)
Admin = Read | Write | Execute | Delete // 15 — manual composition
)
func checkPerm(p, required Permission) bool {
return p&required == required // all bits must be set
}
// --- Skipping with _ (file sizes) ---
const (
_ = iota // 0 — ignored (we don't need a "Byte" unit)
KB = 1 << (10 * iota) // 1 << 10 = 1024
MB // 1 << 20
GB // 1 << 30
TB // 1 << 40
PB // 1 << 50
)
// --- iota in expressions (state machine states) ---
type State int
const (
StateIdle State = iota // 0
StateConnecting // 1
StateConnected // 2
StateDisconnecting // 3
StateError = -1 // explicit value, iota continues below
)
// --- iota for array index alignment ---
const (
ColorRed = iota
ColorGreen
ColorBlue
ColorCount // 3 — use as array size: [ColorCount]string
)
var colorNames = [ColorCount]string{"red", "green", "blue"}
Adding String() to enums
go
// Go has no built-in enum string representation. Use `stringer` or manual.
// Manual approach — explicit, no code generation:
type State int
const (
StateIdle State = iota
StateConnecting
StateConnected
)
var stateNames = [...]string{"idle", "connecting", "connected"}
func (s State) String() string {
if s < 0 || int(s) >= len(stateNames) {
return fmt.Sprintf("State(%d)", s)
}
return stateNames[s]
}
// `go generate` + `stringer` tool approach:
//go:generate stringer -type=State -output=state_string.go
// Produces a fast String() method — recommended for large enums.
Named Types — Compile-Time Safety
go
// A `type` declaration creates a NEW type with the same underlying type.
// Named types are NOT assignable to their underlying type without conversion.
type UserID int64
type AccountID int64
func namedTypeSafety() {
var uid UserID = 42
var aid AccountID = 100
// ❌ Compile error: cannot use uid (type UserID) as type AccountID
// _ = uid + aid
// ✅ Must convert explicitly — this is the safety feature:
combined := int64(uid) + int64(aid) // ok — both converted to int64
_ = combined
// Methods on named types:
fmt.Println(uid) // calls UserID.String() if defined, else prints the number
}
// Domain modeling with named types — prevents entire classes of bugs:
type (
Celsius float64
Fahrenheit float64
Kelvin float64
)
func (c Celsius) ToF() Fahrenheit { return Fahrenheit(c*9/5 + 32) }
func (c Celsius) ToK() Kelvin { return Kelvin(c + 273.15) }
func (c Celsius) String() string { return fmt.Sprintf("%.1f°C", c) }
// ⚠️ Named types share operations with their underlying type:
// Celsius(100) > Celsius(50) // ✅ comparison works (float64 comparison)
// Celsius(100) + Celsius(50) // ✅ arithmetic works (float64 arithmetic)
// Celsius(100) + 50.0 // ❌ untyped 50.0 works, but typed float64 doesn't
Type Inference Rules
go
func typeInference() {
var i = 42 // int (default for integer literals)
var f = 3.14 // float64 (default for float literals)
var s = "hello" // string
var b = true // bool
var r = 'A' // rune (int32) — single quotes = rune literal
// Inferred from function return type:
var ctx = context.Background() // context.Context
// ⚠️ Numeric literal defaults:
// integer → int
// float → float64
// rune → rune (int32)
// There's NO way to make `x := 42` infer int64 — use `var x int64 = 42`
// Inferred from composite literal:
m := map[string]int{"a": 1} // map[string]int
sl := []int{1, 2, 3} // []int
st := struct{ X int }{X: 5} // anonymous struct
// ⚠️ nil has no type — can't infer:
// var x = nil // ❌ compile error: use of untyped nil
var p *int = nil // ✅ explicit type
_ = p
}
The Blank Identifier _
go
// `_` discards a value. It's a write-only identifier — you can't read it.
// 1. Discard unwanted return values:
_, err := os.Open("file.txt") // don't care about the file, just the error
for _, v := range items { // don't care about the index
_ = v
}
// 2. Discard an assignment to suppress "unused variable" error:
func _suppress() {
x := expensiveComputation()
_ = x // suppress unused error — but this is a code smell, prefer to use x
}
// 3. Import for side effects (blank import):
import _ "github.com/lib/pq" // runs pq's init() to register the postgres driver
// 4. Interface satisfaction check (compile-time assertion):
var _ io.Reader = (*MyReader)(nil) // fails to compile if *MyReader doesn't satisfy io.Reader
// 5. Explicitly ignore a channel receive:
<-done // wait for done signal, discard the value
💡 Tips & Tricks
- Safety:
type UserID int64prevents passing aUserIDwhere anAccountIDis expected — the compiler catches the bug. Use named types for all domain IDs, currency amounts, and measurement units. The zero cost (no runtime overhead) makes this a no-brainer. - Idiom: design structs so the zero value is immediately usable —
sync.Mutex{},bytes.Buffer{},http.Server{}all work without initialization. If a field has no sensible zero value, make it unexported and require a constructor. - Performance: untyped constants avoid conversion overhead —
const Size = 1024compiles to a literal instruction with zero conversion. Typed constants may need an implicit conversion at each use site. - Idiom: use
var _ Interface = (*Type)(nil)at package scope to assert interface satisfaction at compile time — catches breakage when a method signature changes. - Debug:
go vet -shadow(orgolangci-lintwithshadowenabled) catches variable shadowing —x := 10inside anifblock that shadows an outerx. Enable in CI. - Idiom:
iotawith1 << iotais the clean way to define bit flags — each flag gets a distinct power-of-2 value. Compose with|(OR), test with&(AND), remove with&^(AND NOT).
⚠️ Edge Cases & Gotchas
:=requires at least one new variable:x, y := 1, 2thenx, y := 3, 4→ compile error (no new variable). Use=.:=in a new scope shadows:if true { x := 5 }declares a newx, not reassigning outerx. Use=to modify the outer variable.- nil slice vs empty slice:
var s []int(nil,s == niltrue) vss := []int{}(non-nil, empty).json.Marshal(nil)→null;json.Marshal([]int{})→[]. APIs may treat these differently. - nil interface vs nil concrete value:
var p *int = nil; var i any = p; i == nil→false. The interface holds(*int, nil), not a nil interface. See chapter 12. - Untyped constant overflow:
const Big = 1 << 100is fine (arbitrary precision), butvar x int = Big→ compile error (overflows int). Untyped constants only overflow when assigned to a type. var x = nilis illegal:nilhas no type, so type inference fails. Usevar x *int = nil.- Named types and literals:
var c Celsius = 25.0works (literal is untyped, adapts toCelsius), butvar c Celsius = float64(25.0)→ compile error (typed value needs conversion). iotaincrements per line: in aconstblock,iotais 0 on the first line, 1 on the second — even if a line doesn't useiota.const ( A = iota; B = 10; C = iota )→ A=0, B=10, C=2 (not 1).- Constants can't reference runtime values:
const Now = time.Now()→ compile error. Usevarfor runtime-computed values. - Zero value of
time.Durationis 0:var d time.Duration→ 0 (not "no duration").0 * time.Second= 0ns. This is meaningful — checkd == 0to detect "unset".
🧠 Quick Quiz
go
const x = 1 << 62
var y int = x
var z int64 = x
var w float64 = x
fmt.Println(y, z, w)
What happens?
Answer
All three assignments compile and work:
4611686018427387904 4611686018427387904 4.611686018427388e+18
1 << 62 = 4,611,686,018,427,387,904, which fits in:
int(64-bit: max 2^63-1) ✅int64(max 2^63-1) ✅float64(can represent integers up to 2^53 exactly; beyond that, precision loss) —wprints in scientific notation with rounding
The key: x is an untyped constant with arbitrary precision. It adapts to each type's context. If it were const x = 1 << 65, the int and int64 assignments would fail (overflow) but float64 would still work (float64 can represent large exponents).
📚 What's Next
→ 04 — Basic Types & Conversions — integer overflow behavior, float64 precision, rune vs byte, string internals, and safe conversion patterns.