<-

Golang Type Aliases, Type Definitions and Real types

This post is going to address uncertainty around Type aliases and Type definitions. Some would apply aliases to make code "well documented". Others would create a type definitions to make code more "typesafe". Both strategies are wrong, at least in my opinion.

Golang type declaration syntax

Let's start with the Type declaration. Type declaration syntax defines syntax for both type alias and type definition. Type declaration binds an identifier, the type name, to a type. It comes in two forms:

Type declarations:

  • Type definition - creates a new, distinct type with the same underlying type and operations as the given type, and binds an identifier to it.
  • Type alias - an alias declaration binds an identifier to the given type. And it serves as an alias for the type.

An alias declaration has the form:

type T1 = T2

as opposed to a type definition:

type T1 T2

An alias declaration doesn't create a new distinct type different from the type it's created from. It just introduces an alias name T1, an alternate spelling, for the type denoted by T2.

More examples:

type Node struct { // Type definition
value interface{}
Left *Node
Right *Node
}
type MyNode = Node // Type alias

When is it supposed to be used?

Type alias

Type aliases are not meant for everyday use. They were introduced to support gradual code repair while moving a type between packages during large scale refactoring. And should be used only for that purpose. Codebase Refactoring (with help from Go)

Type definition

Type definitions on top of basic type should be used only in a very limited number of cases. For example when you create enum using int type or when you want to add additional behaviour to a concrete type.

Type definition - distinct type on top of basic types

What do I want to talk about is the case when you do want to create a new distinct type for basic golang types like byte, int, string, slice, map etc.

There are two cases when you do want to create a new type for primitive type:

1. Constants using iota

Constant for Vehicle type:

type Vehicle int
const (
Bike Vehicle = iota
Scooter
Car
Bus
Train
)

It has a valid reason as we have created a new type and limited value range of it. Using it throughout the codebase makes code more readable and safe.

2. Additional behaviour

In unusual cases when you need to add some additional behaviour to basic types, you can create a new type with method:

type Person string
func (p Person) Hello(anotherPerson Person) {
fmt.Printf("%s, Hello from %s", anotherPerson, p)
}
elon := Person("Elon Musk")
aleh := Person("Aleh")
elon.Hello(aleh) // Aleh, Hello from Elon Musk

How you shouldn't use type definitions?

Below is an example of code adapted from the real project on which I worked:

User struct that as you can guess defines user model:

package app
type User {
Firstname Firstname
Lastname Lastname
Username Username
Password Password
Email Email
DOB DOB
}

How readable is it? In my opinion, it is double readable, I understood from the first time that Firstname is Firstname. But what type is it? Lookup is needed to determine that. Let's supposed we jumped to it:

package app
type Firstname string
type Lastname string
type Username string
type Password string
type Email string
type DOB time.Time

Sometimes you can also find code with type aliases instead of type declarations:

type Firstname = string
type Lastname = string
type Username = string
type Password = string
type Email = string
type DOB = time.Time

Why would somebody do it?

In my case, that were developers who dreamed about type-safety like in Scala.

  • Some believe that it makes the code more readable.
  • Some believe that it will prevent human mistakes.

Let's suppose we have the function doMagic:

func doMagic(firstname Firstname, lastname Lastname, username Username, email Email) MagicResult {}

You use it like that:

doMagic(user.Firstname, user.Lastname, user.Username, user.Email) // cool
doMagic(user.Lastname, user.Firstname, user.Username, user.Email) // type error

Now suppose that you don't have user struct, but have string values:

doMagic(app.Firstname("Ilon"), app.Lastname("Musk"), app.Username("spacemusk"))

What prevents you from passing any random string with those typecasts? Nothing.

// no errors, but logic error present
doMagic(app.Firstname("Musk"), app.Lastname("Ilon"), app.Username("spacemusk"))

Want to have additional documentation, create additional struct like:

type MagicArgs struct {
Firstname string
Lastname string
Username string
}

And use it passing to function doMagic.

doMagic(MagicArgs{
Firstname: "Ilon",
Lastname: "Musk",
Username: "spacemusk",
})

It will prevent you from passing the wrong attribute to the function parameter. How often do you make such mistakes?

Problems

The problem here is that when you create new type distinct type from primitive, you can't use it anymore like primitive. If you have a function like:

func deleteByUsername(username string) (*User, error) { ... }
user := getUser() // return User
deleteByUsername(user.Username) // error Username is not a string
deleteByUsername(string(user.Username)) // OK now

If you want to have Fullname function on User struct:

func (u *User) Fullname() {
return string(u.Firstname) + " " + string(u.Lastname)
}

So each time you want to use a real type of that field you have to cast.

Want to call the format function on DOB? You have to cast it first to time.Time and after that, you will be able to use it.

dob := user.DOB
dobTime := time.Time(dob)
dobTime.Format(time.UnixDate)

Cool right? To use time. The time you have to cast it first to time.Time.

The funny story happens when developers create two distinct types for the equivalent thing in the same project but different packages. Let's imagine that we have functionality in another package where someone defined the function:

package report
func getUserReport(username Username) (*Report, error) { }

And suppose we have User struct from package app

package another
user := getCurrentUser() // returns app.User
report.getUserReport(user.Username) // error app.Username != report.Username
report.getUserReport(report.Username(user.Useranme)) // and you have to use it like that. What a type safety!

How helpful that we have types? In my opinion not very helpful.

In some projects you can find also such types:

type Entity string
type Entities []Entity
type ArrayOfEntities []Entities
type ArrayOfArrayOfEntities []ArrayOfEntities
type ArrayOfArrayOfArrayOfEntities []ArrayOfArrayOfEntities

What would you prefer [][][][]string or ArrayOfArrayOfArrayOfEntities?

Conclusion

Create good software by using essential ideas, principles, best practices. Write code which is well designed, pleasant to read, has tests and documentation. Then you will fill confident with types or without. You don't need to invent any type-safety to create resilient software. Good luck!

Links