What Are Typed vs Untyped Constants in Go

Typed constants have a fixed type at declaration, while untyped constants infer their type based on usage context, offering greater flexibility.

The blank check problem

You write a small utility that retries failed HTTP requests. You define a retry limit at the top of the file. Later you pass that constant to a function expecting a uint8. It compiles. You change the constant to explicitly be a uint8. Now the same call fails with a type mismatch. You stare at the screen wondering why Go suddenly cares about the exact integer width.

Go constants behave differently from variables. Variables lock in a type the moment you declare them. Constants can stay open until the compiler needs to know their type. This design removes boilerplate casts while keeping type safety intact. The tradeoff is understanding when the compiler decides to lock a constant down.

Constants in Go are compile-time values. They never occupy memory at runtime. The compiler evaluates them, folds them into expressions, and replaces them with literal values before generating machine code. This means the flexibility of untyped constants carries zero performance cost.

How Go handles constants at compile time

An untyped constant is a value without a fixed type. The compiler stores it as a high-precision number or string until a context demands a specific type. When you assign it to a variable, pass it to a function, or use it in an expression, the compiler checks whether the value fits the target type. If it fits, the compiler implicitly converts it. If it does not, compilation fails.

A typed constant is bound to a type immediately. The compiler treats it exactly like a variable of that type, except it remains a compile-time constant. You cannot implicitly convert a typed constant to a different type. You must write an explicit conversion.

Think of an untyped constant like a blank check. The bank fills in the currency when you present it, as long as the amount does not exceed the account balance. A typed constant is a check already stamped with a currency. You cannot cash it in a different currency without visiting an exchange desk and writing a conversion.

Go assigns default types to untyped constants when they are used in a context that does not specify a type. The defaults are int, rune, float64, complex128, or string. This prevents the compiler from guessing wildly while still allowing flexibility.

Untyped constants in action

Here is the simplest comparison. An untyped constant adapts to the target. A typed constant refuses to change.

package main

import "fmt"

// Untyped: the compiler keeps this as a high-precision float until assignment
const Pi = 3.14159

// Typed: locked to float64 immediately
const PiTyped float64 = 3.14159

func main() {
    // Target is float32. Pi fits, so the compiler narrows it automatically
    var small float32 = Pi
    fmt.Println(small)

    // Target is float32. PiTyped is already float64.
    // The compiler refuses implicit narrowing between typed values
    // var small2 float32 = PiTyped // compile error
}

The untyped constant Pi flows into float32 without a cast. The typed constant PiTyped blocks the assignment. Go forces you to acknowledge the precision loss with an explicit float32(PiTyped). This prevents silent data truncation in mathematical code.

Constants are cheap. Type flexibility is free. Explicit conversions are intentional.

What the compiler actually does

When the compiler encounters an untyped constant, it does not allocate memory. It stores the exact value in an internal representation that preserves full precision. During type checking, the compiler walks the abstract syntax tree and resolves types bottom-up. When it reaches an assignment or function call, it matches the constant against the expected type.

If the expected type is a basic type like int8, float32, or string, the compiler checks two things. First, it verifies that the constant's kind matches the target's kind. You cannot assign an untyped integer to a bool. Second, it checks bounds. An untyped integer 1000 fits in int16, but it overflows int8. The compiler rejects out-of-range values before generating code.

If the expected type is an interface, the compiler picks the default type for the untyped constant. An untyped integer becomes int. An untyped float becomes float64. This behavior keeps interface values predictable.

The compiler also folds constant expressions at compile time. If you write const SecondsInDay = 24 * 60 * 60, the compiler multiplies the values during compilation and stores the result 86400. No multiplication happens at runtime. This is why Go developers freely use arithmetic in constant declarations.

Real world configuration

Configuration structs often mix different integer widths. Database connection limits might use int16. Timeout durations might use time.Duration (which is an int64 under the hood). Untyped constants make it easy to populate these fields without cluttering the code with casts.

package main

import "time"

// Untyped constants adapt to whatever field needs them
const (
    MaxConnections = 100
    RetryDelay     = 500
    Timeout        = 30
)

type Config struct {
    MaxConns int16
    Delay    time.Duration
    Timeout  time.Duration
}

func NewConfig() Config {
    // MaxConnections fits int16, so assignment succeeds
    // RetryDelay and Timeout are multiplied by time.Millisecond
    // The compiler resolves the untyped ints to time.Duration (int64)
    return Config{
        MaxConns: MaxConnections,
        Delay:    time.Duration(RetryDelay) * time.Millisecond,
        Timeout:  time.Duration(Timeout) * time.Second,
    }
}

The multiplication with time.Millisecond works because time.Millisecond is itself an untyped constant in the time package. The compiler multiplies two untyped integers, resolves the result to time.Duration, and stores the final value. No runtime conversion overhead occurs.

Go developers rarely type constants unless they need to force a specific width early. Leaving them untyped is the idiomatic choice for magic numbers and configuration values.

Where the flexibility breaks

Untyped constants are powerful, but they are not magic. The compiler enforces strict rules. Violating them produces clear errors.

Assigning an untyped constant to a narrower type triggers an overflow check. If you write var small int8 = 300, the compiler rejects the program with constant 300 overflows int8. The error tells you exactly which value failed and which type it exceeded.

Mixing typed and untyped constants in arithmetic can also cause friction. If you add a typed float64 constant to an untyped integer, the compiler promotes the untyped integer to float64. If you add two typed constants of different integer widths, the compiler refuses the operation. You get mismatched types int32 and int64 in binary operation. The fix is an explicit conversion or leaving one of them untyped.

The iota generator behaves differently depending on whether you type the first constant. If you declare const ( A = iota; B = iota ), both are untyped integers. If you declare const ( A int = iota; B = iota ), both are typed int values. The type of the first constant in a block applies to all subsequent constants in that block unless they specify their own type. This rule catches many subtle bugs where developers expect iota to reset types automatically.

Goroutine leaks and channel deadlocks are runtime problems. Constant errors are compile-time problems. The compiler catches them early, which is exactly how Go prefers to handle mistakes.

When to type and when to leave it open

Use an untyped constant when you want the compiler to infer the most specific type at the point of use. Use an untyped constant for configuration values, magic numbers, and iota sequences where the exact width does not matter until assignment. Use an untyped constant when you plan to pass the value to multiple functions with different parameter types.

Use a typed constant when you need to enforce a specific width or precision immediately. Use a typed constant when the value is too large for the default type and you want the compiler to reject accidental downgrades. Use a typed constant when you are building a public API and want to document the exact type contract in the constant declaration itself.

Use explicit conversions when you must bridge typed constants across different widths. Use explicit conversions when you intentionally want to truncate or widen a value and need to signal that decision to future readers.

Constants are compile-time promises. Keep them open until the code demands precision.

Where to go next