Deep Dive Into the Go Type System

Go has a very interesting type system. It eschews classes and inheritance in favor of interfaces and composition, but on the other hand it doesn’t have templates or generics. The way it handles collections is also unique.

Deep Dive Into the Go Type System

In this tutorial you’ll learn about the ins and outs of the Go type system and how to effectively utilize it for writing clear and idiomatic Go code.

The Big Picture of the Go Type System

The Go type system supports the procedural, object-oriented and functional paradigms. It has very limited support for generic programming. While Go is a decidedly static language, it does provide enough flexibility for dynamic techniques via interfaces, first class functions, and reflection. Go’s type system is missing capabilities that are common in most modern languages:

  • There is no exception type since Go’s error handling is based on return codes and the error interface.
  • There is no operator overloading.
  • There is no function overloading (same function name with different parameters).
  • There are no optional or default function parameters.

Those omissions are all by design in order to make Go as simple as possible.

Type Aliases

You can alias types in Go and create distinct types. You can’t assign a value of the underlying type to an aliased type without conversion. For example, the assignment var b int = a in the following program causes a compilation error because the type Age is an alias of int, but it is not an int:

package main


type Age int

func main() {
    var a Age = 5
    var b int = a
}


Output:

tmp/sandbox547268737/main.go:8: cannot use a (type Age) as
type int in assignment

You can group type declarations or use one declaration per line:

type IntIntMap map[int]int
StringSlice []string

type (
    Size   uint64
    Text  string
    CoolFunc func(a int, b bool)(int, error)
)

Basic Types

All the usual suspects are here: bool, string, integers and unsigned integers with explicit bit sizes, floating point numbers (32-bit and 64-bit), and complex numbers (64-bit and 128-bit).

bool
string
int  int8  int16  int32  int64
uint uint8 uint16 uint32 uint64 uintptr
byte // alias for uint8
rune // alias for int32, represents a Unicode code point
float32 float64
complex64 complex128

Strings

Strings in Go are UTF8 encoded and thus can represent any Unicode character. The strings package provides a slew of string operations. Here is an example of taking an array of words, converting them to proper case, and joining them to a sentence.

package main

import (
    "fmt"
    "strings"
)

func main() {
    words := []string{"i", "LikE", "the ColORS:", "RED,", 
                      "bLuE,", "AnD", "GrEEn"}
    properCase := []string{}
    
    for i, w := range words {
        if i == 0 {
            properCase = append(properCase, strings.Title(w))
        } else {
            properCase = append(properCase, strings.ToLower(w))
        }
    }
    
    sentence := strings.Join(properCase, " ") + "."
    fmt.Println(sentence)
}

Pointers

Go has pointers. The null pointer (see zero values later) is nil. You can get a pointer to a value using the & operator and get back using the * operator. You can have pointers to pointers too.

package main

import (
    "fmt"
)


type S struct {
    a float64
    b string
}

func main() {
    x := 5
    px := &x
    *px = 6
    fmt.Println(x)
    ppx := &px
    **ppx = 7
    fmt.Println(x)
}

Object-Oriented Programming

Go supports object-oriented programming via interfaces and structs. There are no classes and no class hierarchy, although you can embed anonymous structs within structs, which provide a sort of single inheritance.

For a detailed exploration of object-oriented programming in Go, check out Let’s Go: Object-Oriented Programming in Golang.

Interfaces

Interfaces are the cornerstone of the Go type system. An interface is just a collection of method signatures. Every type that implements all the methods is compatible with the interface. Here is a quick example. The Shape interface defines two methods: GetPerimeter() and GetArea(). The Square object implements the interface.

type Shape interface {
    GetPerimeter() uint
    GetArea() uint
}

type Square struct {
   side  uint
}

func (s *Square) GetPerimeter() uint {
    return s.side * 4
}

func (s *Square) GetArea() uint {
    return s.side * s.side
}

The empty interface interface{} is compatible with any type because there are no methods that are required. The empty interface can then point to any object (similar to Java’s Object or C/C++ void pointer) and is often used for dynamic typing. Interfaces are always pointers and always point to a concrete object.

For a whole article on Go interfaces, check out: How To Define and Implement a Go Interface.

Structs

Structs are Go’s user-defined types. A struct contains named fields, which may be basic types, pointer types, or other struct types. You can also embed structs anonymously in other structs as a form of implementation inheritance.

In the following example, the S1 and S2 structs are embedded in the S3 struct, which also has its own int field and a pointer to its own type:

package main

import (
    "fmt"
)


type S1 struct {
    f1 int
}

type S2 struct {
    f2 int
}

type S3 struct {
    S1
    S2
    f3 int
    f4 *S3
}


func main() {
    s := &S3{S1{5}, S2{6}, 7, nil}
    
    fmt.Println(s)
}

Output:

&{{5} {6} 7 <nil>}

Type Assertions

Type assertions let you convert an interface to its concrete type. If you already know the underlying type, you can just assert it. If you’re not sure, you can try several type assertions until you discover the right type.

In the following example, there is a list of things that contains strings and non-string values represented as a slice of empty interfaces. The code iterates over all the things, trying to convert each item to a string and store all the strings in a separate slice that it eventually prints.

package main

import "fmt"

func main() {
    things := []interface{}{"hi", 5, 3.8, "there", nil, "!"}
    strings := []string{}
    
    for _, t := range things {
        s, ok := t.(string)
        if ok {
            strings = append(strings, s)
        }
    }
    
    fmt.Println(strings)
    
}

Output:

[hi there !]

Reflection

The Go reflect package lets you directly check the type of an interface without type assertions. You can also extract the value of an interface and convert it to an interface if you wish (not as useful).

Here is a similar example to the previous example, but instead of printing the strings it just counts them, so there is no need to convert from interface{} to string. The key is calling reflect.Type() to get a type object, which has a Kind() method that allows us to detect if we’re dealing with a string or not.

package main

import (
    "fmt"
    "reflect"
)


func main() {
    things := []interface{}{"hi", 5, 3.8, "there", nil, "!"}
    stringCount := 0
    
    for _, t := range things {
        tt := reflect.TypeOf(t)
        if tt != nil && tt.Kind() == reflect.String {
            stringCount++
        }
    }
    
    fmt.Println("String count:", stringCount)
}

Functions

Functions are first class citizens in Go. That means that you can assign functions to variables, pass functions as arguments to other functions, or return them as results. That enables you to use the functional programming style with Go.

The following example demonstrates a couple of functions, GetUnaryOp() and GetBinaryOp(), that return anonymous functions selected randomly. The main program decides if it needs a unary operation or a binary operation based on the number of arguments. It stores the selected function in a local variable called “op” and then invokes it with the correct number of arguments.

package main

import (
    "fmt"
    "math/rand"
)

type UnaryOp func(a int) int
type BinaryOp func(a, b int) int


func GetBinaryOp() BinaryOp {
    if rand.Intn(2) == 0 {
        return func(a, b int) int { return a + b }
    } else {
        return func(a, b int) int { return a - b }
    }
}

func GetUnaryOp() UnaryOp {
    if rand.Intn(2) == 0 {
        return func(a int) int { return -a }
    } else {
        return func(a int) int { return a * a }
    }
}


func main() {
    arguments := [][]int{{4,5},{6},{9},{7,18},{33}}
    var result int
    for _, a := range arguments {
        if len(a) == 1 {
            op := GetUnaryOp()
            result = op(a[0])            
        } else {
            op := GetBinaryOp()
            result = op(a[0], a[1])                    
        }
        fmt.Println(result)                
    }
}

Channels

Channels are an unusual data type. You can think of them as message queues used for passing messages between goroutines. Channels are strongly typed. They are synchronized and have dedicated syntax support for sending and receiving messages. Each channel can be receive-only, send-only, or bi-directional.

Channels can also be optionally buffered. You can iterate over the messages in a channel using range, and go routines can block on multiple channels at the same time using the versatile select operation.

Here is a typical example where the sum of squares of a list of integers is computed in parallel by two go routines, each one responsible for half of the list. The main function waits for results from both go routines and then adds up the partial sums for the total. Note how the channel c is created using the make() built-in function and how the code reads from and writes to the channel via the special <- operator.

package main

import "fmt"

func sum_of_squares(s []int, c chan int) {
    sum := 0
    for _, v := range s {
        sum += v * v
    }
    c <- sum // send sum to c
}

func main() {
    s := []int{11, 32, 81, -9, -14}

    c := make(chan int)
    go sum_of_squares(s[:len(s)/2], c)
    go sum_of_squares(s[len(s)/2:], c)
    sum1, sum2 := <-c, <-c // receive from c
    total := sum1 + sum2

    fmt.Println(sum1, sum2, total)
}

This is just scraping the surface. For a detailed review of channels, check out:

  • Deep Dive Into the Go Type System
    Go
    Let’s Go: Golang Concurrency, Part 2
    Gigi Sayfan

Collections

Go has several built-in generic collections that can store any type. These collections are special, and you can’t define your own generic collections. The collections are arrays, slices, and maps. Channels are also generic and can be considered collections too, but they have some pretty unique properties, so I prefer to discuss them separately.

Arrays

Arrays are fixed-sized collections of elements of the same type. Here are some arrays:

package main
import "fmt"


func main() {
    a1 := [3]int{1, 2, 3}
    var a2 [3]int
    a2 = a1 

    fmt.Println(a1)
    fmt.Println(a2)
    
    a1[1] = 7

    fmt.Println(a1)
    fmt.Println(a2)
    
    a3 := [2]interface{}{3, "hello"}
    fmt.Println(a3)
}

The array’s size is part of its type. You can copy arrays of the same type and size. The copy is by value. If you want to store items of different type, you can use the escape hatch of an array of empty interfaces.

Slices

Arrays are pretty limited due to their fixed size. Slices are much more interesting. You can think of slices as dynamic arrays. Under the covers, slices use an array for storing their elements. You can check the length of a slice, append elements and other slices, and most fun of all you can extract sub-slices similar to Python slicing:

package main

import "fmt"



func main() {
    s1 := []int{1, 2, 3}
    var s2 []int
    s2 = s1 

    fmt.Println(s1)
    fmt.Println(s2)

    // Modify s1    
    s1[1] = 7

    // Both s1 and s2 point to the same underlying array
    fmt.Println(s1)
    fmt.Println(s2)
    
    fmt.Println(len(s1))
    
    // Slice s1
    s3 := s1[1:len(s1)]
    
    fmt.Println(s3)
}

When you copy slices, you just copy the reference to the same underlying array. When you slice, the sub-slice still points to the same array. But when you append, you get a slice that points to a new array.

You can iterate over arrays or slices using a regular loop with indexes or using ranges. You can also create slices in a given capacity that will be initialized with the zero value of their data type using the make() function:

package main

import "fmt"



func main() {
    // Create a slice of 5 booleans initialized to false    
    s1 := make([]bool, 5)
    fmt.Println(s1)
    
    s1[3] = true
    s1[4] = true

    fmt.Println("Iterate using standard for loop with index")
    for i := 0; i < len(s1); i++ {
        fmt.Println(i, s1[i])
    }
    
    fmt.Println("Iterate using range")
    for i, x := range(s1) {
        fmt.Println(i, x)
    }
}

Output:

[false false false false false]
Iterate using standard for loop with index
0 false
1 false
2 false
3 true
4 true
Iterate using range
0 false
1 false
2 false
3 true
4 true

Maps

Maps are collections of key-value pairs. You can assign them map literals or other maps. You can also create empty maps using the make built-in function. You access elements using square brackets. Maps support iteration using range, and you can test if a key exists by trying to access it and checking the second optional boolean return value.

package main

import (
    "fmt"
)

func main() {
    // Create map using a map literal
    m := map[int]string{1: "one", 2: "two", 3:"three"}
    
    // Assign to item by key
    m[5] = "five"
    // Access item by key
    fmt.Println(m[2])
    
    v, ok := m[4]
    if ok {
        fmt.Println(v)
    } else {
        fmt.Println("Missing key: 4")
    }
    
    
    for k, v := range m {
        fmt.Println(k, ":", v)
    }
}

Output:

two
Missing key: 4
5 : five
1 : one
2 : two
3 : three

Note that iteration is not in creation or insertion order.

Zero Values

There are no uninitialized types in Go. Each type has a predefined zero value. If a variable of a type is declared without assigning it a value, then it contains its zero value. This is an important type safety feature.

For any type T, *new(T) will return one zero value of T.

For boolean types, the zero value is “false”. For numerical types, the zero value is… zero. For slices, maps and pointers, it’s nil. For structs, it’s a struct where all the fields are initialized to their zero value.

package main

import (
    "fmt"
)


type S struct {
    a float64
    b string
}

func main() {
    fmt.Println(*new(bool))
    fmt.Println(*new(int))
    fmt.Println(*new([]string))
    fmt.Println(*new(map[int]string))
    x := *new([]string)
    if x == nil {
        fmt.Println("Uninitialized slices are nil")
    }

    y := *new(map[int]string)
    if y == nil {
        fmt.Println("Uninitialized maps are nil too")
    }
    fmt.Println(*new(S))
}

What About Templates or Generics?

Go has none. This is probably the most common complaint about Go’s type system. The Go designers are open to the idea, but don’t know yet how to implement it without violating the other design principles underlying the language. What can you do if you badly need some generic data types? Here are a few suggestions:

  • If you only have a few instantiations, consider just creating concrete objects.
  • Use an empty interface (you’ll need to type assert back to your concrete type at some point).
  • Use code generation.

Conclusion

Go has an interesting type system. The Go designers made explicit decisions to stay on the simple side of the spectrum. If you’re serious about Go programming, you should invest the time and learn about its type system and its idiosyncrasies. It will be well worth your time.