- Book: The Complete Guide to Go Programming
- Also by me: Hexagonal Architecture in Go — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You write a shift to build a bitmask. 1 << 40, a flag for the 41st
bit. It compiles. A month later someone changes the shift amount to
come from a config value instead of a literal, and the same
expression starts returning zero on the same machine. No panic, no
warning. The bitmask that guarded a feature flag is now permanently
off, and the only thing that changed was whether the shift amount
was a constant.
That is the difference between a Go constant and a Go variable, and
most of the time you never notice it. Constants in Go live in a
separate world with different rules: arbitrary precision, no fixed
type until the last moment, and a compile-time overflow check you
get for free. Understanding that world is the difference between the
compiler catching your overflow at build time and the CPU silently
eating it at runtime.
Two kinds of constant
Every constant in Go is either typed or untyped. The keyword looks
the same. The behavior does not.
const untypedShift = 1 << 40 // untyped
const typedShift int64 = 1 << 40 // typed
untypedShift has no type yet. It is a value, 1099511627776, and
it will pick up a type only when you use it somewhere that needs one.
typedShift is nailed to int64 from the moment you declare it.
That sounds like a small distinction. It changes what the compiler
can check, what arithmetic is allowed, and where an overflow gets
caught.
Untyped constants carry arbitrary precision
An untyped constant is not stored in an int or a float64. The Go
spec requires the compiler to represent constant integers with at
least 256 bits of precision, and the gc compiler uses arbitrary
precision internally. So intermediate results in constant expressions
do not overflow the way runtime arithmetic does.
const huge = 1 << 100 // 2^100, fits in no Go integer type
const scaled = huge >> 98 // back down to 4
func main() {
fmt.Println(scaled) // 4
}
huge is far larger than int64 or uint64 can hold. It never
touches a machine register, so it never overflows. The whole
expression is evaluated at compile time in full precision, and only
the final scaled, which is 4, has to fit into a real type when
you print it.
The same holds for floating-point constants. Division is done at
full precision and rounded once, at the end:
const third = 1.0 / 3.0 // evaluated exactly, then rounded
var x float64 = third // rounding happens here
The rounding to float64 occurs at the assignment, not during the
division. You get one rounding step instead of several.
Default types: what an untyped constant becomes
An untyped constant stays untyped as long as it can. The moment you
use it in a context that needs a concrete type, it converts. If the
context does not specify a type, the constant falls back to its
default type, decided by its kind:
| Constant kind | Default type |
|---|---|
| integer | int |
| floating-point | float64 |
| rune |
rune (int32) |
| complex | complex128 |
| boolean | bool |
| string | string |
This is why the two lines below behave differently:
const n = 42
i := n // i is int (default type)
var f float64 = n // n converts to float64 here, f is 42.0
n is one constant. In the first use it becomes an int. In the
second it becomes a float64. No cast needed, because an untyped
constant converts to any type it fits into. A typed constant would
force you to write the conversion yourself:
const m int = 42
var g float64 = m // compile error: cannot use m (int) as float64
var g2 float64 = float64(m) // explicit cast required
That extra friction is the point of a typed constant. It stops the
value from flowing into a type you did not intend.
The free overflow check
Here is where the untyped constant earns its place. Assign a constant
to a type it does not fit, and the compiler rejects it before the
binary exists.
const shift = 40
var x int32 = 1 << shift // compile error
// constant 1099511627776 overflows int32
The expression 1 << shift is an untyped constant, so the compiler
computes its full value and checks it against int32. It does not
fit, so the build fails. You cannot ship this bug.
Now move the shift amount into a variable and watch the check
disappear:
var shift uint = 40
var x int32 = 1 << shift // compiles fine, x == 0 at runtime
This is the bug from the opening. Once shift is a variable, the
shift is a non-constant expression. The Go spec says the untyped
1 on the left takes the type it would have on its own in this
context, which is int32. So the runtime computes int32(1) << 40,
which shifts the only set bit clean off the top of a 32-bit value.
The result is 0. No error, no overflow flag, just a silently wrong
number.
The lesson is not "avoid variable shifts." It is that constant
expressions get a correctness check that runtime expressions never
will. When a value can be a constant, keeping it a constant buys you
that check at zero cost.
When a typed constant earns the keyword
Untyped is the right default. It is the most flexible form, and it is
what you write most of the time. A typed constant is worth the extra
keyword in three cases.
One: you want overflow caught in constant arithmetic. A typed
constant pins the type, so later arithmetic on it is checked against
that type at compile time.
const limit uint8 = 200
const doubled = limit * 2 // compile error
// constant 400 overflows uint8
Because limit is uint8, 2 converts to uint8, and 200 * 2
overflows the byte. The compiler stops you. Had limit been untyped,
doubled would just be the untyped constant 400, no error, and the
overflow would wait to bite whoever assigned it to a byte later.
Two: you are building an enum with iota. A named type keeps the
values from mixing with plain integers.
type Level int
const (
Debug Level = iota
Info
Warn
Error
)
func setLevel(l Level) { /* ... */ }
setLevel(Warn) // fine
var raw int = 2
setLevel(raw) // compile error: int is not Level
A bare literal like setLevel(2) still compiles, because an untyped
2 converts to Level on its own. What the named type buys you is
that a value already typed as int, such as a variable, a function
return, or a struct field, can no longer flow into setLevel by
accident. An untyped iota block gives you none of that guard.
Three: you want the type documented at the source. A
time.Duration constant, a uint16 port number, a domain type for
money. The type is part of the contract, so you write it once at the
declaration instead of at every call site.
The rule
Default to untyped. It keeps arbitrary precision, converts to whatever
fits, and gives you the compile-time overflow check for free.
Reach for a typed constant when you want the compiler to enforce a
type: to catch overflow inside constant arithmetic, to build an enum
that will not accept stray integers, or to document a domain type at
the declaration.
And when a value guards something that matters, keep it a constant as
long as you can. The moment it becomes a variable, you trade a
build-time error for a runtime surprise, and the compiler stops
watching your back.
Constants sit at the seam between the language spec and the code you
actually write, which is exactly the kind of detail that decides
whether a bug is caught at build time or in production. The Complete
Guide to Go Programming works through the constant model, default
types, and the runtime rules that turn a silent shift into a zero.
Hexagonal Architecture in Go is the companion for keeping typed
domain constants at the right boundary, so the value that means
something is defended by the compiler instead of a comment.

Top comments (0)