Go's untyped constants are one of the language's more elegant features — until they silently do something you didn't intend. This post is about a specific class of bug that untyped numeric constants can introduce, why the fix is simpler than you might think, and a naming convention that makes the intent impossible to misread.
What untyped constants actually are
When you declare a constant in Go without an explicit type, it becomes an untyped constant. It doesn't have a fixed type yet — it carries a kind (integer, float, string, bool) and a value, but its actual type is deferred until it's used.
const Timeout = 5 // untyped integer constant
const Ratio = 0.95 // untyped float constant
const Label = "worker" // untyped string constant
This is genuinely useful. An untyped integer constant can be assigned to any integer type without an explicit conversion:
var a int = Timeout // fine
var b int64 = Timeout // fine
var c float64 = Timeout // fine
The compiler resolves the type at the point of use. This is why you can write make([]byte, 1024) without casting — 1024 is an untyped integer constant that fits whatever integer type make needs.
So far so good. The problem is that this implicit resolution extends to named types built on top of numeric primitives — and some of those named types carry semantic meaning that a bare number does not.
The bug: time.Duration and the silent conversion
Here's the situation I ran into. I had a constant for a worker pool job timeout:
const JobTimeout = 5
And a function that used it:
func startWorker(timeout time.Duration) {
timer := time.NewTimer(timeout)
// ...
}
startWorker(JobTimeout)
This compiles. No warning. No error. Go happily passes 5 as a time.Duration.
The problem: time.Duration is defined as type Duration int64, measured in nanoseconds. So JobTimeout = 5 becomes a 5-nanosecond timeout. The worker timer fires almost instantly. In testing against a fast local environment, the timeout path was easy to miss. Under load with a slow downstream, every job timed out almost immediately.
The intent was 5 minutes. What actually ran was 5 nanoseconds.
// What I meant
5 * time.Minute // 300,000,000,000 nanoseconds
// What Go silently did
time.Duration(5) // 5 nanoseconds
The compiler had no way to know my intent because I gave it no information about units. I just said "5". Go resolved it to time.Duration(5) and moved on.
Why this specific bug is hard to catch
Two things make it dangerous:
It compiles cleanly. There's no type mismatch because untyped integer constants are assignable to any integer-based named type. The type system doesn't protect you here.
It's invisible in fast environments. A 5-nanosecond timer almost never fires before the work completes on a modern CPU. The bug only manifests under conditions (slow network, slow DB, high load) that don't appear in unit tests running against local mocks.
This is the worst class of bug: no compile error, no test failure, silent wrong behavior in production.
This is not just a time.Duration problem
While revisiting this post, I found several examples of the same underlying issue showing up in different forms. They are useful because they make the problem feel less like a personal mistake and more like a real edge in Go's constant model.
The Go spec is precise about this behavior: operations on untyped constants keep producing untyped constants, and when different untyped numeric kinds are mixed, the result follows the spec's kind-ordering rules. That is elegant, but it means expression shape matters.
One Reddit thread demonstrates this with a tiny expression:
0.5 * 1/2 == 1/2 * 0.5
Those look mathematically equivalent, but in Go they do not behave the same way. In 1/2 * 0.5, the 1/2 part is evaluated as an untyped integer constant first, so it becomes 0 before the multiplication by 0.5. The original poster described hitting the same pattern inside a numeric algorithm, where a small-looking constant mistake created a much larger real-world error.
That is the floating-point cousin of the time.Duration bug. In both cases, the compiler is following the language rules. The code is the thing that failed to say what it meant.
There is also an open golangci-lint issue requesting an untypedconst linter. The requested check would warn when an untyped constant is passed where a defined type is expected. That is exactly the boundary this post is about:
startWorker(5) // untyped constant crosses into time.Duration
startWorker(5 * time.Minute)
The first call is legal because 5 can become a time.Duration. The second call is clear because the unit is part of the expression.
Other issues show adjacent sharp edges:
- A Go compiler issue discusses a huge untyped integer constant that eventually produced a constant overflow error, even though untyped constants are often described as living in an "ideal" numeric space.
- An older
go/typesissue involved subtle behavior around an untyped integer constant in a shift and conversion expression. - A
go-oraissue shows a real dependency hitting linter failures for2147483648 (untyped int constant) overflows int. - Another Reddit thread calls out loss of precision when using untyped constants, which is the same family of problem: the value is legal, but the intended numeric semantics are easier to lose than the code suggests.
The lesson I take from these examples is not "avoid untyped constants everywhere." That would be an overcorrection. Untyped constants are one of the reasons Go numeric literals are pleasant to use.
The lesson is narrower:
When a constant crosses a semantic boundary, make the semantics explicit.
Named types like time.Duration, enum-like custom types, byte counts, ports, limits, and unit-bearing values are semantic boundaries. A bare number crossing one of those boundaries deserves suspicion.
The fix: give the constant the correct type — time.Duration
The fix is to declare the constant with the type it is meant to have — time.Duration — and to write its value with an explicit unit:
// ❌ Untyped int — coerced to time.Duration(5), i.e. 5 nanoseconds
const JobTimeout = 5
// ✅ Typed as time.Duration, value written with its unit
const JobTimeout time.Duration = 5 * time.Minute
There is a subtlety here that is the whole point: two separate things are happening on that second line, and you need both.
First, the type is time.Duration. That is what stops the constant from being silently accepted where an int or some other numeric type is expected.
Second, the value is 5 * time.Minute, not 5. That is what makes the magnitude correct — 300,000,000,000 nanoseconds instead of 5.
Specifying the type alone does not save you. const JobTimeout time.Duration = 5 compiles, is type-safe, and is still 5 nanoseconds — the original bug, now wearing a type. For a unit-bearing type like time.Duration, "specify the correct type" and "write the unit" are the same discipline: the only sensible way to express a time.Duration magnitude is with a unit, and once you do, the constant is already a time.Duration whether or not you add the annotation.
So why annotate at all, if 5 * time.Minute is already a time.Duration? For intent at the declaration and to enforce the discipline in review. const JobTimeout time.Duration = 5 * time.Minute states the contract plainly — this is a duration. It does not make = 5 a compile error, but it makes a bare number visibly suspicious because the type and the missing unit no longer agree with the intent.
Now pass the typed constant to a function that does not accept time.Duration:
func startWorker(timeout time.Duration) { ... }
func processJob(count int) { ... }
startWorker(JobTimeout) // ✅ fine
processJob(JobTimeout) // ❌ compile error: cannot use JobTimeout (constant 300000000000 of type time.Duration) as int value in argument to processJob
The typed constant carries its semantic meaning into every use site, and the compiler enforces it.
Going one step further: encode the unit in the name
Even with a typed constant, consider what happens when someone reads just the constant name at a call site:
startWorker(JobTimeout)
They know it's a time.Duration from the type, but they have to jump to the declaration to know what 5 minutes means in context. Is that a reasonable timeout? Too long? Too short?
The convention I've adopted: put the unit in the constant name when the value is a pure magnitude.
// Unit in the name — intent readable at every call site
const JobTimeoutMinutes time.Duration = 5 * time.Minute
const RetryDelaySeconds time.Duration = 30 * time.Second
const CacheTTLHours time.Duration = 24 * time.Hour
Now every call site is self-explanatory:
startWorker(JobTimeoutMinutes)
scheduleRetry(RetryDelaySeconds)
setCache(key, value, CacheTTLHours)
A reader doesn't need to look at the declaration. They know the unit from the name, and if they do check the declaration, the type and the multiplier confirm it.
The pattern generalises beyond time.Duration
time.Duration is the most common place this matters, but the same principle applies to any named type that wraps a primitive:
// Byte sizes
const MaxPayloadBytes int64 = 10 * 1024 * 1024 // 10 MB
const ChunkSizeKB int64 = 64 * 1024
// Counts that could be confused with durations or sizes
const WorkerPoolSize int = 10
const MaxRetryCount int = 3
// Custom named types in your own code
type Port int
type TimeoutMS int64
const HTTPSPort Port = 443
const QueryTimeout TimeoutMS = 500
The rule of thumb: if a constant is a magnitude of something — time, bytes, counts tied to a specific resource — give it an explicit type and encode the unit in the name. If it's a dimensionless scalar (a ratio, a flag, a pure count), the type is less critical but still worth being explicit about.
What about typed constants and iota?
One more case where explicit types matter: iota-based enumerations. Without a type, the values are just untyped integers and can be mixed accidentally.
// ❌ Untyped iota — nothing prevents mixing State and Priority values
const (
StateIdle = iota // 0
StateRunning // 1
StateDone // 2
)
const (
PriorityLow = iota // 0
PriorityNormal // 1
PriorityHigh // 2
)
func setWorkerState(s int) { ... }
setWorkerState(PriorityHigh) // compiles, silently wrong
With explicit types, the compiler catches the mix-up:
// ✅ Typed iota — compiler enforces correct usage
type WorkerState int
type Priority int
const (
StateIdle WorkerState = iota
StateRunning
StateDone
)
const (
PriorityLow Priority = iota
PriorityNormal
PriorityHigh
)
func setWorkerState(s WorkerState) { ... }
setWorkerState(PriorityHigh) // ❌ compile error: cannot use PriorityHigh (constant 2 of type Priority) as WorkerState value in argument to setWorkerState
The type system now enforces semantic correctness, not just structural compatibility.
Summary
const Timeout = 5 passed to a time.Duration param — Risk: silent nanosecond conversion. Fix: const TimeoutMinutes time.Duration = 5 * time.Minute.
const Size = 1024 passed to a byte-count param — Risk: ambiguous unit. Fix: const MaxSizeBytes int64 = 1024.
Untyped iota constants mixed across enumerations — Risk: silent wrong value. Fix: an explicit named type per enumeration.
Go's untyped constants exist for good reasons — they make numeric literals flexible and reduce casting noise. But that flexibility comes with a cost: the compiler can't infer your intent, only your value. When a constant represents a quantity with a unit, make the unit explicit in both the type and the name. The compiler becomes your ally instead of a silent bystander.
The bug that prompted this post took me an embarrassingly long time to track down. A 5-nanosecond timer that "worked fine in testing" is the kind of thing that ages you. Explicit types and unit-named constants are cheap insurance against it.
References and related examples
- The Go specification: Constant expressions
- The Go blog: Constants
- Interesting gotcha with untyped numeric constants
- Footgun: loss of precision when using untyped constants
- golangci-lint issue: Add
untypedconstlinter - Go issue #66776: untyped int constant gives me an overflow error
- Go issue #5849: incorrect type reported for untyped constant in conversion
- go-ora issue #597: untyped int constant overflows int
This is part of a series on Go patterns and production gotchas. Previous posts cover concurrency mistakes, context propagation, interface design, and profiling with pprof.
Top comments (0)