Golang type system in depth

Go promotes itself as a simple language, and it is. But when you try to do advanced tasks you found that there are a lot of features and rules that go has to introspect, define and take advantage of their type system. Many people compare Go's type system with TypeScript and Rust and many more languages, most of them inspired by Functional Programming (many FP languages has a robust and feature rich type system). Obviously, those languages have a more feature rich type system, but it doesn't mean that you are forced to use any or interface{} every time you need to do complex tasks. Today we are going to review the Go type system and which features this language provides to us. ℹ️ This article is not for beginners, nor for the kind of hackers who could hack NASA using just HTML and gallons of coffee. It's for everyone who has done programming at some point in their life. Primitives Go has the following primitive types boolean int, int8, int16, int32, int64 uint, uint8, uint16, uint32, uint64, uintptr float32, float64 complex64, complex128 byte rune The only ones that I want to delve deep into are: int: this is an integer type that accepts both negative and positive numbers. It has a variable size, and it depends on the bit size of the architecture used during compilation. For humans, if you are going to compile your program for an Intel x86 architecture (32 bit), the int type will have a size of 32 bits. But if you compile the program for intel x86_64 (64 bit), a.k.a. amd64, the int type will use 64 bits. uint: unsigned integers. That means that it does not accept negative integers. As the int type, its size depends on the architecture of the CPU. uintptr: an unsigned integer that is used to save pointer values. Technically, it has the same size as uint but the purpose of uintptr is to save specifically pointers and when you are working with the unsafe package most of their functions expect you to use uintptr. So don't be a try-hard (like C programmers) - use the corresponding types when working with pointers. Go is trying to protect you from manual memory management and pointer arithmetic... There are reasons for that. byte: an alias of uint8. You can use a byte where an uint8 is required and vice versa. It is used to distinguish a simple uint8 from a byte in contexts like I/O operations. var num uint8 = 5 var b byte = num rune: an alias of int32. It's used to represent characters. Remember that Go supports Unicode, and a rune represents a single Unicode code point — which fits in 32 bits. var num int32 = 5 var character rune = num ℹ️ In this article, I talk about type alias and custom types. So if your brain said goodbye while reading these two, don't worry. Just hug the knowledge for now. For curious minds... Complex numbers are a combination of real numbers (floating point numbers) and imaginary numbers (a factor of iii ). So, a complex number looks like this: 5+4i 5+4i 5+4i As you can deduct complex numbers are using 64 and 128 because they internally use two numbers. If they use float32 to store numbers they need 64 bits and if they use float64 they will use 128 bits. Zero values Each data type in Go has a zero value. This is the value that a variable holds if at the time of creation has no assignment. var num int // 0 is the zero value var text string // "" is the zero value var array [4]{} // [0, 0, 0, 0] is the zero value var array []{} // nil is the zero value // nil is the zero value also for maps, pointers and functions Array, slices and maps Go also supports some collections and data structures out of the box. Arrays: Are a fixed-size collection of values placed on the same memory region. For humans, again, a list of values placed one next to the other. The order of values is preserved, that means that if you put an item A and then an item B the first item will be always A. // An array with an explicit specified size var array1 = [4]int{1, 2, 3, 4} // An array with size determined by their content var array2 = [...]int{1, 2, 3, 4} When I say that in an array, elements are side by side I mean that if an array is holding 5 int8 values. The elements are located like this inside your RAM. var a [5]int8 = [5]int8{10, 20, 30, 40, 50} Memory layout (RAM): [ ][ ][ ][ ][ ][ ][ ][ ] [ ][ ][ ][ ][ ][ ][ ][ ] [10][20][30][40][50][ ][ ][ ] ^ ^ ^ ^ ^ | | | | | a[0] a[1] a[2] a[3] a[4] Slices: Slices are like arrays with a dynamic size, they grow in capacity when needed. For people that don't like simplified explanations, a slice contains a pointer, a size and a capacity. They are one of the biggest black box that Go has. So if you want a deep explanation of Go slices, let me know, and I will write an article just about them. // An slice does not specify the size var slice1 = []int{1, 2, 3, 4} // We can create an array from an array or another

Apr 20, 2025 - 18:57
 0
Golang type system in depth

Go promotes itself as a simple language, and it is. But when you try to do advanced tasks you found that there are a lot of features and rules that go has to introspect, define and take advantage of their type system.

Many people compare Go's type system with TypeScript and Rust and many more languages, most of them inspired by Functional Programming (many FP languages has a robust and feature rich type system). Obviously, those languages have a more feature rich type system, but it doesn't mean that you are forced to use any or interface{} every time you need to do complex tasks.

Today we are going to review the Go type system and which features this language provides to us.

ℹ️ This article is not for beginners, nor for the kind of hackers who could hack NASA using just HTML and gallons of coffee. It's for everyone who has done programming at some point in their life.

Primitives

Go has the following primitive types

  • boolean
  • int, int8, int16, int32, int64
  • uint, uint8, uint16, uint32, uint64, uintptr
  • float32, float64
  • complex64, complex128
  • byte
  • rune

The only ones that I want to delve deep into are:

int: this is an integer type that accepts both negative and positive numbers. It has a variable size, and it depends on the bit size of the architecture used during compilation.

For humans, if you are going to compile your program for an Intel x86 architecture (32 bit), the int type will have a size of 32 bits. But if you compile the program for intel x86_64 (64 bit), a.k.a. amd64, the int type will use 64 bits.

uint: unsigned integers. That means that it does not accept negative integers. As the int type, its size depends on the architecture of the CPU.

uintptr: an unsigned integer that is used to save pointer values. Technically, it has the same size as uint but the purpose of uintptr is to save specifically pointers and when you are working with the unsafe package most of their functions expect you to use uintptr. So don't be a try-hard (like C programmers) - use the corresponding types when working with pointers. Go is trying to protect you from manual memory management and pointer arithmetic... There are reasons for that.

byte: an alias of uint8. You can use a byte where an uint8 is required and vice versa. It is used to distinguish a simple uint8 from a byte in contexts like I/O operations.

var num uint8 = 5
var b byte = num

rune: an alias of int32. It's used to represent characters. Remember that Go supports Unicode, and a rune represents a single Unicode code point — which fits in 32 bits.

var num int32 = 5
var character rune = num

ℹ️ In this article, I talk about type alias and custom types. So if your brain said goodbye while reading these two, don't worry. Just hug the knowledge for now.

For curious minds... Complex numbers are a combination of real numbers (floating point numbers) and imaginary numbers (a factor of iii ). So, a complex number looks like this:

5+4i 5+4i 5+4i

As you can deduct complex numbers are using 64 and 128 because they internally use two numbers. If they use float32 to store numbers they need 64 bits and if they use float64 they will use 128 bits.

Zero values

Each data type in Go has a zero value. This is the value that a variable holds if at the time of creation has no assignment.

var num int // 0 is the zero value
var text string // "" is the zero value
var array [4]{} // [0, 0, 0, 0] is the zero value
var array []{} // nil is the zero value
// nil is the zero value also for maps, pointers and functions

Array, slices and maps

Go also supports some collections and data structures out of the box.

Arrays: Are a fixed-size collection of values placed on the same memory region. For humans, again, a list of values placed one next to the other. The order of values is preserved, that means that if you put an item A and then an item B the first item will be always A.

// An array with an explicit specified size
var array1 = [4]int{1, 2, 3, 4}
// An array with size determined by their content
var array2 = [...]int{1, 2, 3, 4}

When I say that in an array, elements are side by side I mean that if an array is holding 5 int8 values. The elements are located like this inside your RAM.

var a [5]int8 = [5]int8{10, 20, 30, 40, 50}
Memory layout (RAM):

[  ][  ][  ][  ][  ][  ][  ][  ]
[  ][  ][  ][  ][  ][  ][  ][  ]
[10][20][30][40][50][  ][  ][  ]
 ^   ^   ^   ^   ^
 |   |   |   |   |
a[0] a[1] a[2] a[3] a[4]

Slices: Slices are like arrays with a dynamic size, they grow in capacity when needed. For people that don't like simplified explanations, a slice contains a pointer, a size and a capacity. They are one of the biggest black box that Go has. So if you want a deep explanation of Go slices, let me know, and I will write an article just about them.

// An slice does not specify the size
var slice1 = []int{1, 2, 3, 4}
// We can create an array from an array or another slice
// [2, 3]
var slice2 = slice1[1:3]

Maps: Maps are a data structure that allows you to associate a key with a value. A key can be any comparable type, discussed on type constraints, and a value could be any value.

var myDictionary = map[string]int{
        "foo": 5,
        "bar": 4
}

Functions

Yes, functions are also a data type and this opens us the door to crazy things.

// this is a function that recieves a string and returns a string
// func(string) string
func Greet(name string) string {
        return "Hello " + name
}

Type alias and custom types

Go allows us to define custom types and type alias. While it can look the same, they are not.

Type alias

This is a type alias. In this case, an age and an uint8 can be swapped without problems. So in fact an age is an uint8 are the same.

type age = uint8

// This is correct ✅
var myAge age = 5
var aSimpleNumber uint8 = myAge

Custom types

This is a custom type. In this case, an age internally holds an uint8. But they are NOT the same. You need to explicitly cast them.

// Note that we removedd =
type age uint8

// Haha good one but no ❌
var myAge age = 5
var aSimpleNumber uint8 = myAge

// This is correct ✅
var myAge age = 5
var aSimpleNumber uint8 = uint8(myAge)

☁️ So when I have to use a type alias and a custom type?

Personally, I rarely use type aliases, normally I use custom types. They allow me to implement methods and restrict the possible values that this type can hold.

Define custom methods

If we have a custom type like age. We can create methods for that data type.

type age uint8

func (a age) CanWatchPorn() bool {
        return a >= 18
}

This useful validation can't be implemented using type alias. If you have a type alias for uint8, and you could implement a method for that type, that method would apply to all uint8 variables. This is not something that we want. Go doesn't allow extending built-in types with methods.

Restrict allowed values

Yet go does not support enums we can play with private types and predefined constant values to allow users of our libraries and codebase which are the allowed values for a specific data types.

First, we can define our custom type

package pkg

type hiddenType int

// allowed values (kinda enum entries)
const (
        A hiddenType = iota
        B
        C
)

// A function that requires this data type
func SomeOperation(value hiddenType) {
        // doing something important (or not)
}

Then in another package we can use the exposed values and functions.

package main

import "example/pkg"

func main() {
        // The type is private so we can not create
        // new variables with that type. We can only
        // use already created values.
        var choice = pkg.A
        pkg.SomeOperation(choice)
}

Structs

Now we know what are the primitive types that go supports and understand how to create custom types, let's talk about structs.

Go does not have classes (sorry Java developers). It uses structs that look like this.

type MyCustomStruct struct {
        PublicField string
        privateField int
}

As you can see, we are creating a custom type that is a struct with a public field (capitalized) and a private field (lower case). Also, we can define structs like this.

// here the type is 'inferred'
var structuredType = struct {
    PublicField  string
    privatefield string
}{
    PublicField:  "",
    privatefield: "",
}

Yes, the type is technically inferred, but common literally we are explicitly writing the fields of the struct while we are declaring it the compiler doesn't have to think so much to know the type of the variable.

The 0 sized value...

When we need to use a variable that has a value with no required space we can use an empty struct.

// This uses literally 0 bits
var empty struct{} = struct{}{}

This kind of value is really useful when with channels, for example, and we want to use a channel not to send a value but to trigger something. This reduces the memory used by our program.

▶️

package main

import (
        "fmt"
        "time"
)

func main() {
        done := make(chan struct{}) // Channel without data

        go func() {
                fmt.Println("Wasting time at work")
                time.Sleep(2 * time.Second) // Simulates job
                done <- struct{}{}          // Send a signal
        }()

        <-done // Waiting for signal
        fmt.Println("Finished!")
}

Embedding

Structs can be embedded inside ohter structs. This is called composition and Go promotes Composition over inheritence.

▶️

package main

import "fmt"

type Greeter struct {
}

func (e Greeter) Greet(name string) {
    fmt.Println("Hello", name)
}

type Wrapper struct {
    Greeter
    Name string
}

func main() {
    var wrapper Wrapper
    // We are calling the same method
    // from wrapper we can access embedded
    // struct methods
    wrapper.Greet("John")
    wrapper.Greeter.Greet("John")
}

Also we can create the same methods on wrapper structs to override the original method.

▶️

package main

import "fmt"

type Greeter struct {
}

func (e Greeter) Greet(name string) {
    fmt.Println("Hello from greeter", name)
}

type Wrapper struct {
    Greeter
    SomeField string
}

func (e Wrapper) Greet(name string) {
    fmt.Println("Hello from wrapper", name)
}

func main() {
    var wrapper Wrapper
    wrapper.Greet("John")
    wrapper.Greeter.Greet("John")
}

We can also access struct fields of embedded structs

Methods

Okay, now we are going to deep into declaring methods. As we have seen earlier, we can play with a lot of primitive types and structs and custom types. But go technically does not have methods but allows us to bind functions to custom types.

▶️

package main

import "fmt"

type Greeter struct {
    Name string
}

func (g Greeter) Greet() {
    fmt.Printf("Hello %s\n", g.Name)
}

// Idiomatic gophers don't take this personal
// this is just for illustrative prupouses
func (g *Greeter) SetFancyName(name string) {
    fancyName := fmt.Sprintf("✨%s✨", name)
    g.Name = fancyName
}

func main() {
    var greeter = Greeter{
        Name: "John",
    }

    greeter.Greet()

    greeter.SetFancyName("Fancy John")
    greeter.Greet()
}

Here we are declaring a custom struct and we are binding two functions. In Java, C++ or JavaScript this would play the role of g.

Note that in SetFancyName we are using a pointer instead of a simple Greeter. This is because in this method we need to update the original value, not a copy of the original value.

We can specify "methods" for any custom type. Literally any...

type EventHandler func(event string) error

func (h EventHanlder) Trigger(event string) {
        // Here we can add common logic
        // for debugging purpouses, for example
        err := h(event)
        if err != nil {
                fmt.Println("Error:", err)
        }
}

Yep, even function types can have methods. That means you can attach behaviors to pretty much anything - and Go will happily compile it. Use with care (and style).

Interfaces

Now one of the most interesting features of Go's type system. Go allows to define interfaces. Similar to Java but go uses duck typing.

If it walks like a duck and it quacks like a duck, then it must be a duck.

Programmers are a little obsessed with ducks...

Okay but what does this mean? This mean that you don't have to explicitly say that your type implements an interface to use it as that interface. You just declare the corresponding methods and that's it.

▶️

package main

import "fmt"

type Greeter interface {
    Greet() string
}

type NameGreeter struct {
    Name string
}

func (g NameGreeter) Greet() string {
    return "Hello, " + g.Name
}

func PrintGreet(g Greeter) {
    fmt.Println(g.Greet())
}

func main() {
    // Ah yes you can omit field name
    // when declaring structs if you respect
    // the declaration order
    greeter := NameGreeter{"John"}

    PrintGreet(greeter)
}

And this is very useful for io operations that make a big usage of io.Reader, io.Writer and io.ReadWriter. Also you can combine multiple interfaces into a single one. Similar to struct embedding.

type Reader {
        Read(p []byte) (int, error)
}

type Writer {
        Write(p []byte) (int, error)
}

type ReadWriter interface {
        Reader
        Writer
}

Any

The any type in Go is a special type. Is a type alias of interface{}. The interface{} is an empty interface. That maeans that is an interface with 0 required methods. Any type can be an empty interface because they don't have to declare any method to be accepted as empty interfaces. To simplify code, Go introduced the type alias any.

Generics

Finally we are arribing to the topic that pushed me to write this blog... ✨Generics✨

Go allows the usage of generics but is quite different from C++, Java, Rust or TypeScript.

You can declare generic types

You can declare generic structs, functions, interfaces, arrays, maps etc. Any of the types discussed above can be generic types.

▶️

package main

import "fmt"

// Declaring a stack using a generic slice
type Stack[T any] []T

// Declaring a generic struct
// That contains a generic value
type Box[T any] struct {
    Value T
}

func main() {
    var stack Stack[int]
    var box = Box[string]{
        Value: "John",
    }

    fmt.Println(box)
    fmt.Println(stack)
}

▶️

package main

import (
    "fmt"
)

// Map applies a function to each element in a slice and returns a new slice.
func Map[T any, R any](input []T, mapper func(T) R) []R {
    result := make([]R, len(input))
    for i, v := range input {
        result[i] = mapper(v)
    }
    return result
}

func main() {
    // Example 1: Square each number
    numbers := []int{1, 2, 3, 4}
    squares := Map(numbers, func(n int) int {
        return n * n
    })
    fmt.Println("Squares:", squares) // Output: [1 4 9 16]

    // Example 2: Convert ints to strings
    strings := Map(numbers, func(n int) string {
        return fmt.Sprintf("Number: %d", n)
    })
    fmt.Println("Strings:", strings) // Output: ["Number: 1", "Number: 2", ...]
}

You cannot declare generic methods

Go said “nope” to generic methods. Why? Because it would mess with the whole point of how interfaces work. In Go, a type either has the method or it doesn’t. If methods could be generic, then suddenly a single method could turn into whatever the interface expects - and this adds a lot of complexity unpredictable behavior. The compiler wants things simple and predictable.

The next example won't compile
▶️

package main

import "fmt"

// Declaring a generic struct
// That contains a generic value
type Box[T any] struct {
    Value T
}

func (b Box[T]) To[V any]() V {
    return b.Value.(V)
}

func main() {
    var box = Box[string]{
        Value: "John",
    }

    fmt.Println(box)

    var s string = box.To()
    fmt.Println(s)
}

Type constraints

Now on the other hand we can create constraints for these type parameters. For example if we want to create a function that returns the index that corresponds to an intem of a slice. For this Go has a type constraint named comparable.

▶️

package main

import "fmt"

// IndexOf returns the index of target in the slice, or -1 if not found.
// T must be comparable so we can use ==.
func IndexOf[T comparable](slice []T, target T) int {
    for i, v := range slice {
        if v == target {
            return i
        }
    }
    return -1
}

func main() {
    ints := []int{10, 20, 30, 40}
    strs := []string{"apple", "banana", "cherry"}

    fmt.Println(IndexOf(ints, 30))     // Output: 2
    fmt.Println(IndexOf(strs, "pear")) // Output: -1
}

In this case this function only works with types that complain the comparable type constraint. We can also specify our custom type constraints. Constraints are just interfaces but instead of declare methods they declare the underlying types or constraints that a type has to be or complain to be a valid type.

▶️

package main

import "fmt"

type CustomConstraint interface {
    int | string
}

type ConstrainedSlice[T CustomConstraint] []T

func IndexOf[T CustomConstraint](slice ConstrainedSlice[T], target T) int {
    for i, v := range slice {
        if v == target {
            return i
        }
    }
    return -1
}

func main() {
    ints := ConstrainedSlice[int]{10, 20, 30, 40}
    strs := ConstrainedSlice[string]{"apple", "banana", "cherry"}

    fmt.Println(IndexOf(ints, 30))     // Output: 2
    fmt.Println(IndexOf(strs, "pear")) // Output: -1
}

But, in this case this only accepts integer or string types. But if we want to accpet types that internally use strings or integers we have to use the ~ modifier.

▶️

package main

import "fmt"

type CustomInt int

// Note the usage of ~
// with this we can use any type that is using
// the specified underlying type
type CustomConstraint interface {
    ~int | ~string
}

type ConstrainedSlice[T CustomConstraint] []T

func IndexOf[T CustomConstraint](slice ConstrainedSlice[T], target T) int {
    for i, v := range slice {
        if v == target {
            return i
        }
    }
    return -1
}

func main() {
    ints := ConstrainedSlice[CustomInt]{10, 20, 30, 40}
    strs := ConstrainedSlice[string]{"apple", "banana", "cherry"}

    fmt.Println(IndexOf(ints, 30))     // Output: 2
    fmt.Println(IndexOf(strs, "pear")) // Output: -1
}

Builtint constraints

Go has an EXPERIMENTAL package named constraints that has a collection of builtin constraints that are very handy when it's time to define constraints that has to accpet any primitive type like integers (which we have a decent amount of them). This is applied for many data types like: floats, complex, integers, etc.

▶️

package main

import (
    "fmt"

    "golang.org/x/exp/constraints"
)

type CustomInt int

type CustomConstraint interface {
    constraints.Ordered
}

type ConstrainedSlice[T CustomConstraint] []T

func IndexOf[T CustomConstraint](slice ConstrainedSlice[T], target T) int {
    for i, v := range slice {
        if v == target {
            return i
        }
    }
    return -1
}

func main() {
    ints := ConstrainedSlice[CustomInt]{10, 20, 30, 40}
    strs := ConstrainedSlice[string]{"apple", "banana", "cherry"}

    fmt.Println(IndexOf(ints, 30))     // Output: 2
    fmt.Println(IndexOf(strs, "pear")) // Output: -1
}

Conclusions

Go has a simple but yet powerful type system that even with their limitations can be used to solve a wide variety of problems that we can find in our daily tasks as developers.