Skip to content
gohacks.dev
Basics

Type Alias vs Defined Type in Go Compared Side by Side

A defined type like type Celsius float64 is new with its own methods, and an alias like type Temp = float64 is another name. Behavior, uses and a verdict.

7 min read
Summarize in AI
Flat illustration of two options weighed side by side.

Two declarations differ by a single character and mean opposite things. type Celsius float64 makes a new type. type Temp = float64 makes a second name for an existing one. The first is how you build a domain model, the second is a refactoring tool, and mixing them up produces either a type that does not protect you or an error about methods on a type that is not yours. This comparison runs both side by side, shows the compiler's response in each direction, covers the one place aliases are the right answer and gives the rule I follow. It builds on type conversion and structs, and sits in the Go basics path.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

What actually differs

Declare one of each and try to use them:

main.goGo
package main

import "fmt"

type Celsius float64
type Temp = float64

type Set[T comparable] = map[T]struct{}

func main() {
	var c Celsius = 20
	var t Temp = 20
	var f float64 = t
	fmt.Println(c, t, f)
	fmt.Printf("%T %T %T\n", c, t, f)

	s := Set[string]{"a": {}}
	fmt.Printf("%T %d\n", s, len(s))

	var b byte = 'x'
	var u uint8 = b
	var r rune = 'y'
	var i int32 = r
	fmt.Printf("%T %T %T %T\n", b, u, r, i)
}
Text
20 20 20
main.Celsius float64 float64
map[string]struct {} 1
uint8 uint8 int32 int32

Read the %T line. c is a main.Celsius, a distinct type, while t prints as float64, because the alias is only another spelling and the type is float64. That means var f float64 = t compiles without a conversion: an alias value is a float64. The generic alias Set[string] prints as map[string]struct {}, and the last line confirms that byte and uint8 are one type, as are rune and int32. Those two are aliases the language predeclares, which is why error messages and %T never mention byte or rune.

The two things you cannot do with an alias

Assigning a defined type to its underlying type needs a conversion in both directions, and the compiler refuses a bare assignment:

main.goGo
package main

import "fmt"

type Celsius float64
type Temp = float64

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

func (t Temp) Double() Temp { return t * 2 }

func main() {
	var c Celsius = 20
	var f float64
	f = c
	fmt.Println(f)
}
Text
# example.test/lab
./main.go:10:9: cannot define new methods on non-local type Temp
./main.go:15:6: cannot use c (variable of float64 type Celsius) as float64 value in assignment

There are two errors. The assignment f = c fails because Celsius and float64 are different types, as the cannot use value as type error explains. That friction is the benefit of a defined type: a function taking a Celsius will not accept a bare float64 from another unit, so you cannot pass a price where a temperature is expected.

The other error is cannot define new methods on non-local type Temp. An alias is the underlying type, float64, and you cannot add methods to a type from the universe or from another package. A defined type, Celsius, is yours and can carry methods, such as the String that formats it. If you want behavior on a basic type, you need a defined type.

What this means in practice

QuestionDefined type type T UAlias type T = U
Is it a new type?YesNo, the same type
Can it have methods?YesNo (unless U is local)
Assignable to and from UOnly with a conversionAlways
What %T printspkg.TThe name of U
Does reflect see it as different?YesNo
Typical useDomain concepts, enums, unitsRefactoring, renaming, short names

The defined type is the default. When I write type UserID int or type Celsius float64, I want the compiler's help in keeping them apart from each other and from raw integers, and I want a place for String, Valid or Parse methods. An alias gives none of that.

The one place aliases are right: moving a type

Aliases were added to Go 1.9 for large refactors. Suppose a type lives in package old and you move it to package new. Every importer of old.User would break. With an alias, the old name continues to work while callers migrate:

newpkg/newpkg.goGo
package newpkg

type User struct {
	Name string
}

func (u User) Greeting() string { return "hello, " + u.Name }
oldpkg/oldpkg.goGo
package oldpkg

import "example.test/lab/newpkg"

// User is the old name for newpkg.User.
//
// Deprecated: Use newpkg.User.
type User = newpkg.User
main.goGo
package main

import (
	"fmt"

	"example.test/lab/newpkg"
	"example.test/lab/oldpkg"
)

func main() {
	var u oldpkg.User = newpkg.User{Name: "ada"}
	var v newpkg.User = u
	fmt.Println(u.Greeting(), v == u)
	fmt.Printf("%T\n", u)
}
Text
hello, ada true
newpkg.User

An oldpkg.User and a newpkg.User are interchangeable, because they are the same type, so existing code keeps compiling while the Deprecated: notice nudges people toward the new name. The %T line prints newpkg.User, confirming that nothing of the old name survives at run time. Had oldpkg.User been a defined type, type User newpkg.User, it would have been a different type with none of the methods, and every caller would break. This is the use case the alias was designed for, and the one the Go team describes in the codebase refactoring design.

Defined types from structs and interfaces

The same rule applies when the underlying type is a struct, with one more consequence. A defined type keeps the fields of its underlying struct and none of its methods:

Go
type User struct{ Name string }

func (u User) Greeting() string { return "hello, " + u.Name }

type Admin User   // a new type: has Name, has no Greeting
type Member = User // the same type: has Name and Greeting

Admin{Name: "ada"}.Greeting() does not compile, because the method set of Admin is empty, and you convert with User(a) to get the methods back. That is how you strip behavior on purpose, for example to avoid an infinite loop when a type's MarshalJSON method wants to call the default encoder: define type plain User inside the method, convert and encode the plain one, which has the fields and no MarshalJSON. A defined type whose underlying type is an interface keeps the interface's method set, since those methods belong to the interface. An alias adds nothing in either case and also takes nothing away.

Generic aliases and short names

Go 1.24 allowed type aliases to have type parameters, as in the Set[T] alias above, which makes Set[string] a convenient name for map[string]struct{} while remaining that exact type. It is the same pure-naming use: a shorter or clearer spelling of an unwieldy type, with nothing new at run time. In a single package, an alias is also a way to give a long composite type a name, type Handlers = map[string]func(http.ResponseWriter, *http.Request), though a defined type there lets you hang methods on it, which is usually better.

My verdict

Use a defined type whenever the thing is a concept: an ID, a unit, a status, a currency amount, a duration. The conversions you pay for are the point. Use an alias when you are renaming or moving a type and want old and new spellings to coexist, or when a generic type is awkward to spell. If you catch yourself writing type Name = string to give a string a clearer name in signatures, that is a defined-type case that wanted the safety: write type Name string, and accept the conversions at the boundary. The cost of the defined type is a conversion where untyped values arrive, such as Name("ada") for a variable and no change for constants, since an untyped constant adapts. For the language rules, see the specification on type declarations, which covers alias declarations and type definitions together.

When the other choice wins

Defined types lose when you need to interoperate with a library that takes the underlying type everywhere and you convert at every call: a type Seconds int passed to ten functions that expect int adds noise without catching errors. Aliases lose the moment you want a method, or a difference in identity for a switch on types. A third option, embedding the underlying type in a struct, is another way to extend a type you do not own, with the costs described in embedding. Whichever you choose, put the declaration where the reader will find the reason: a comment explaining why a name is an alias saves the next person from "fixing" it.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.