Skip to content

Custom types

Go lets you define your own named types. There are two declaration forms — they look almost identical but mean completely different things.

type Celsius float64       // type definition — a new, distinct type
type Temp    = float64     // type alias    — just another name for float64

The single = is the entire difference. The consequences are big.

Why bother creating custom types?

Three reasons, in order of importance:

  1. The compiler stops you from mixing things that shouldn't mix. A Celsius value cannot be added to a Fahrenheit value without you explicitly saying so.
  2. You can attach methods. Only defined types (not aliases) can have methods — covered in a later topic, but it's the main reason to reach for type Foo Bar.
  3. Names communicate intent. UserID is more self-documenting than yet another int64 in a function signature.

Type definition: type Foo Bar

From the Go spec:

"A type definition creates a new, distinct type with the same underlying type and operations as the given type [...]. It is different from any other type, including the type it is created from."

type Celsius float64
type Fahrenheit float64

var c Celsius = 20
var f Fahrenheit = 68

sum := c + f          // compile error: mismatched types Celsius and Fahrenheit

Celsius and Fahrenheit are different types. Both happen to be laid out in memory exactly like a float64 — that shared layout is their underlying type — but the compiler treats them as distinct because they have different names.

You can convert between them with the usual T(x) syntax (covered in 03-type-conversions.md):

c2 := Celsius((f - 32) * 5 / 9)   // ok — explicit conversion

Common patterns

Semantic wrappers around primitives:

type UserID int64
type Email  string
type Money  int64    // store cents to avoid float

func sendEmail(to Email, user UserID) error { /* ... */ }

var id UserID = 42
var addr Email = "ada@example.com"
sendEmail(addr, id)              // ok — argument order matches types

sendEmail(id, addr)              // compile error: order is wrong, compiler catches it

This is a free safety net. Without UserID as its own type, both args would be int64/string and the wrong order would compile silently.

Named function types:

type Handler func(req string) string

func register(h Handler) { /* ... */ }

register(func(req string) string {
    return "echo: " + req
})

Named slice or map types:

type Headers map[string]string
type Tags    []string

func process(h Headers, t Tags) { /* ... */ }

Enum-like constants — typically combined with iota:

type Weekday int

const (
    Sunday Weekday = iota   // 0
    Monday                  // 1
    Tuesday                 // 2
    Wednesday               // 3
    Thursday                // 4
    Friday                  // 5
    Saturday                // 6
)

func isWeekend(d Weekday) bool {
    return d == Sunday || d == Saturday
}

isWeekend(Monday)            // ok — type-checked
isWeekend(5)                 // ok — 5 is an untyped int constant, becomes Weekday
isWeekend(int(5))            // compile error — int is not Weekday

Type alias: type Foo = Bar

From the spec:

"An alias declaration binds an identifier to the given type. Within the scope of the identifier, it serves as an alias for the given type."

An alias is not a new type — it's a second name for the same type.

type Celsius = float64        // alias

var c Celsius = 20
var f float64 = c             // ok — c IS a float64
var sum = c + f               // ok — same type, no conversion needed

Compare to the definition form above: with type Celsius float64 the compiler would reject var f float64 = c. With the alias, it accepts it.

When to use aliases (rarely)

The vast majority of code wants type definitions, not aliases. Aliases exist mainly for:

  1. Gradual refactoring across packages — temporarily expose oldpkg.Foo as newpkg.Foo while callers migrate.
  2. Shortening long type namestype list = []*linkedListNode[T] for readability inside one file.
  3. The standard library uses them sparinglybyte = uint8 and rune = int32 are aliases. That's why byte and uint8 are interchangeable everywhere.
var b byte   = 65
var u uint8  = b              // ok — alias means same type
var c uint32 = uint32(b)      // ok — explicit conversion needed (different types)

Key restriction: you cannot attach methods to an alias. The alias has no separate identity to attach them to.

type Celsius = float64
func (c Celsius) Fmt() string { return "..." }   // compile error

If you want methods, use a type definition.

Methods — the headline feature of type definitions

A quick preview (methods get their own topic later). You attach methods using a receiver:

type Celsius float64

// Method with a value receiver — operates on a copy.
func (c Celsius) Fahrenheit() float64 {
    return float64(c)*9/5 + 32
}

func main() {
    var c Celsius = 100
    fmt.Println(c.Fahrenheit())     // 212
}

Two rules to know now:

  1. The receiver type must be a defined type (or pointer to one). You cannot write func (f float64) ... — you'd have to define type MyFloat float64 first.
  2. The receiver type must be defined in the same package as the method. You cannot add a method to time.Duration from your own package. Workaround: define your own type with time.Duration as its underlying type, attach methods there, convert when crossing boundaries.

"Underlying type" — the precise rule

From the spec, paraphrased into a process:

  1. Predeclared types (int, string, bool, float64, ...) — their underlying type is themselves.
  2. Type literals ([]int, map[string]int, struct{...}, func(...) ...) — their underlying type is themselves.
  3. Any other type — follow the chain of definitions until you reach one of the above.
type A = string         // underlying type: string
type B string           // underlying type: string  (alias chain: B → string)
type C B                // underlying type: string  (chain: C → B → string)
type D struct {         // underlying type: struct{Name string}  (a type literal)
    Name string
}

You can check at runtime with reflect.TypeOf(x).Kind() — it returns the underlying kind, not the named type.

Type identity in one paragraph

Two types are identical in Go's eyes when:

  • They are the same defined type (same name, same package), OR
  • They are both type literals with structurally matching underlying types.

A defined type is never identical to its underlying type or to any other defined type, even if the layouts match. That's the entire reason Celsius and Fahrenheit don't mix.

Quick reference

Form New type? Methods? Mixes with original?
type Foo Bar (definition) yes yes no — needs explicit conversion
type Foo = Bar (alias) no no yes — same type

Sources