HowtoGo
Home / Notes / Defined Types vs. Type Aliases
Notes

Defined Types vs. Type Aliases

type Celsius float64 and type Fahrenheit = float64 differ by one equals sign. One makes a new type, the other makes a nickname, and the compiler treats them nothing alike.

One equals sign apart

Without the equals sign, you get a new type that happens to be laid out like a float64.

With it, you get a second name for float64 itself. Nothing new exists.

// A defined type. Celsius is a brand new type.
type Celsius float64

// An alias. Fahrenheit is another name for float64.
type Fahrenheit = float64

The compiler treats them differently

An alias is the type it names, so it assigns straight across.

A defined type is its own type. The compiler refuses even though the memory is identical.

var c Celsius = 100
var f Fahrenheit = 212
var plain float64

plain = f // fine: Fahrenheit is float64
plain = c // compile error

The error names the underlying type, which is the giveaway: Go knows Celsius is a float64 underneath, and still will not do it for you.

Compiler
./main.go:8:10: cannot use c (variable of float64 type Celsius)
    as float64 value in assignment

An explicit conversion is the fix. It costs nothing at runtime. It is there so the swap is visible in the code.

plain = float64(c) // explicit conversion, no data changes

Only a defined type can have methods

A defined type is yours, so you can attach methods to it.

func (c Celsius) String() string {
    return fmt.Sprintf("%.1f°C", float64(c))
}

fmt.Println(c) // 100.0°C

An alias is not yours. Fahrenheit is float64, and float64 belongs to the language, so there is nothing local to attach a method to.

This is the practical reason most new types are defined types rather than aliases.

Compiler
// Trying the same on the alias:
func (f Fahrenheit) String() string { return "" }

./main.go:5:9: cannot define new methods on non-local type Fahrenheit

They print differently

%T asks the runtime what a value is. It reports Celsius by name, and reports the alias as plain float64, because that is what it is.

Terminal
fmt.Printf("%T\n", c) // main.Celsius
fmt.Printf("%T\n", f) // float64

A type switch can only see one of them

A type switch matches on real types, so a defined type gets its own case.

switch v := x.(type) {
case Celsius:
    // only Celsius lands here
case float64:
    // Fahrenheit lands here, because it is float64
}

An alias cannot have its own case. Writing both is the same case twice, and the compiler says so.

Compiler
// Listing both is a duplicate:
case float64:
case Fahrenheit:

./main.go:9:7: duplicate case Fahrenheit in type switch
    ./main.go:7:7: previous case

When to reach for each

Reach for a defined type when the distinction matters. Two IDs that are both int64 are easy to swap by accident, and a defined type turns that into a compile error.

type UserID int64
type OrderID int64

func lookup(id UserID) {}

var o OrderID = 7
lookup(o) // compile error, which is the point

Reach for an alias when two names must mean the same type, which in practice means moving a type between packages without breaking callers.

This is why byte and rune exist. They are aliases for uint8 and int32, there to say which meaning is intended.

// Moving Config from oldpkg to newpkg without breaking callers.
package oldpkg

type Config = newpkg.Config

// oldpkg.Config and newpkg.Config are the same type, so existing
// code keeps compiling while callers migrate one at a time.

Side by side

Defined typeAlias
Syntaxtype T Utype T = U
Is it a new typeYesNo, same type as U
Assign to a UNeeds a conversionDirect
Can define methodsYesOnly if U is local to the package
What %T printsThe new nameThe original name
Own type switch caseYesNo, duplicates U's case
Reach for it whenThe distinction should be enforcedTwo names must mean one type