02 / 20 · Day 1
Day 1 · Concept 02

Variables, types, constants

Go is statically typed but mostly invisibly so. The compiler infers types from the right-hand side; you almost never write a type annotation. The walrus operator := is the workhorse — declare and assign in one move. Once you internalise zero values, half of Go's edge cases stop surprising you.


1 · The intuition

Most languages with type inference still feel verbose because their syntax wants a lot of words (const x: number = 42). Go's bet: pick the right default operator, and 90% of declarations need no annotations at all.

There are two declaration forms you'll see. var x int = 42 is the long form — explicit and rare. x := 42 is the short form — declare and infer in one move. The short form is package-private to functions; you can't use := at file scope. That's the only rule that catches newcomers.

Zero values. Go has no concept of "uninitialised". Every variable has a value the moment it's declared. int starts at 0, string at "", bool at false, pointers at nil, structs at the zero of every field. This sounds boring; it eliminates a whole class of bugs.

2 · Try it

go main.go · the three declaration forms + zero values
package main

import "fmt"

func main() {
    // Long form — explicit type
    var name string = "Go"

    // Long form with inferred type
    var age = 15

    // Short form — declare and infer, function-scope only
    isAwesome := true

    fmt.Printf("%s, age %d, awesome=%v
", name, age, isAwesome)

    // Zero values — declare without initialising
    var counter int
    var greeting string
    var flag bool
    fmt.Printf("zero values: %d, %q, %v
", counter, greeting, flag)
}

3 · The numeric types — and why there are so many

Go has explicit sized integer types: int8, int16, int32, int64 and unsigned variants uint8uint64. There's also a plain int which is "the size of a register on your machine" — 64 bits on every modern platform.

var byteVal byte = 255           // alias for uint8
var smallInt int16 = -30000
var bigInt int64 = 9_000_000_000  // shows for readability
var unsigned uint = 42            // platform-sized

var pi float64 = 3.14159           // most common float
var smaller float32 = 1.5         // half the precision

var ch rune = '⌘'                  // alias for int32; a Unicode code point
var letter byte = 'a'              // alias for uint8; an ASCII byte

Use int by default. Reach for sized integers when memory layout matters (parsing a binary protocol) or interop demands it. Use byte for raw bytes, rune for Unicode code points. Don't mix the two — that's day 6 territory.

4 · Constants

Constants are like variables but the value is fixed at compile time. They have a quirk: they can be untyped, which makes the math more flexible.

const Pi = 3.14159                // untyped float constant
const greeting = "hello"          // untyped string
const maxRetries int = 5          // typed int constant

// Untyped constants take the type of the context they're used in.
var radius float32 = 2
var area = Pi * radius * radius   // Pi promotes to float32 here

fmt.Println(area)                  // 12.56636

iota — the secret enum power tool

iota is a compile-time counter inside a const block. It resets to 0 each block and increments per line. The classic use: enums.

type Weekday int

const (
    Sunday    Weekday = iota  // 0
    Monday                     // 1 (iota auto-applies to subsequent lines)
    Tuesday                    // 2
    Wednesday                  // 3
    Thursday                   // 4
    Friday                     // 5
    Saturday                   // 6
)

// Bit-flag enums use shifting
const (
    ReadPerm   = 1 << iota  // 1
    WritePerm               // 2
    ExecPerm                // 4
)

var perms = ReadPerm | WritePerm  // 3

5 · Type conversions — explicit always

Go does not auto-convert between numeric types. Even int and int64 are different types; assigning one to the other without a conversion is a compile error. The fix is a one-word cast.

var x int = 42
var y int64 = int64(x)     // explicit conversion required
var z float64 = float64(x) // ditto

// Won't compile: var y int64 = x
//   "cannot use x (variable of type int) as int64 value"

// Useful: convert byte slice to string and back
bs := []byte("hello")
s := string(bs)
fmt.Println(s, bs)
Why so strict? Implicit conversions are how subtle bugs live: a uint32 that silently truncates to uint16, an int that overflows when promoted to float64. Go's rule: if a conversion can lose information, you write it. The compiler can't be wrong about your intent if you stated it.

6 · From the wild — real Go

Snippets from Kubernetes's API types. Notice — type aliases, iota-style enums via untyped constants, and zero values being meaningful (a zero NodeCondition means "unset").

go kubernetes · api/core/v1/types.go (excerpts)
// NodeConditionType is a valid value for NodeCondition.Type
type NodeConditionType string

// These are valid conditions of a node. Currently, we don't have enough
// information to decide node condition.
const (
    NodeReady              NodeConditionType = "Ready"
    NodeMemoryPressure     NodeConditionType = "MemoryPressure"
    NodeDiskPressure       NodeConditionType = "DiskPressure"
    NodePIDPressure        NodeConditionType = "PIDPressure"
    NodeNetworkUnavailable NodeConditionType = "NetworkUnavailable"
)

// PodResourceClaimStatus is stored in the PodStatus for each PodResourceClaim
// which references a ResourceClaim or ResourceClaimTemplate.
type PodResourceClaimStatus struct {
    // Name uniquely identifies this resource claim inside the pod.
    Name string

    // ResourceClaimName is the name of the ResourceClaim that was generated
    // for the Pod in the namespace of the Pod. It this is unset, then generating
    // a ResourceClaim was not necessary.
    ResourceClaimName *string
}
From the wild: github.com/kubernetes/kubernetes · Apache 2.0

The named-string type (NodeConditionType) gives type safety: you can't pass an arbitrary string where a NodeConditionType is expected. The zero value of *string is nil, which means "unset" — the API uses pointers specifically to distinguish "explicitly empty" from "not provided".

7 · Coming from another language?

If you know…What's familiarWhat surprises
Python := looks like Python 3.8's walrus. Type-inference feels like dynamic typing. Types are real at runtime — you cannot pass a string where an int is expected. No duck typing.
Java / C# Static typing. Multiple integer widths. Constants. No type annotation needed for most declarations. No final keyword — every variable is mutable; that's what const is for.
JavaScript / TypeScript :=const. Type inference. The walrus. No let vs const distinction — every Go variable is mutable. const in Go means "compile-time constant", not "can't reassign".
Rust Numeric types with explicit widths. Strong inference. No ownership / borrowing — variables are just values. Untyped constants are more flexible than Rust's const.
C / C++ Sized integers, explicit conversions, zero-as-uninitialised. No pointer arithmetic. Strings are real (length-prefixed, immutable). The compiler is much louder about unused things.

8 · Exercises (~10 min)

  1. Type spy. Use fmt.Printf("%T\n", x) on each of: 42, 3.14, "go", '⌘', true. Predict each type before running. Anything surprise you?
  2. The untyped constant. Write const Pi = 3.14. Use it as a float32 in one expression and float64 in another. Notice — it adapts. Now write const Pi float64 = 3.14 and try the same. What error does the compiler give you?
  3. The shadow trap. Inside a function: declare x := 1. Inside an if true { } block, write x := 2 then print x. After the block, print x again. Predict, then run. (This is the shadowing bug pattern.)
  4. Make an enum. Define a type Status int with values Pending, Active, Suspended, Cancelled using iota. Print them — what do they show as? Hint: they're just integers unless you also write a String() string method (covered on day 4).

9 · Common mistakes

  • Using := at package level. Package-scope variables need var x = 5. The walrus only works inside functions.
  • Shadowing with :=. If even one variable on the left is new, := declares it; existing names get re-assigned. x, err := foo() after x := bar() creates a new err but re-uses x. If you write x, err := foo() in a nested scope where outer x exists, you get an inner shadowed x — a classic subtle bug.
  • Confusing untyped and typed constants. const Pi = 3.14 is untyped and flexes; const Pi float32 = 3.14 is fixed at float32. The untyped form composes better in math.
  • Forgetting zero values are real. var users map[string]User declares a nil map, which panics on write. Use users := map[string]User{} or users := make(map[string]User) instead.
  • Naming variables type, map, chan, len. These are built-ins, not keywords — you can shadow them but you shouldn't. Pick a different name.

10 · When it clicks

  • You reach for := by reflex inside functions and var by reflex at file scope.
  • You don't write type annotations unless the compiler complains — and you can guess when it will.
  • You read const ( Sunday = iota; Monday; Tuesday ) and immediately know it's an enum.
  • You know that var users map[string]User is a footgun and reach for make.
Found this useful?