DEV Community

Mohamed Edrah
Mohamed Edrah

Posted on • Updated on

Creating simple self documenting types in Go

Go has a rich type system, we can leverage it to better express ourselves in code and not resort to using things like comments or documentation.

for example consider the following composite type:

type member struct {
    id   string
    img  string
    desc string
}
Enter fullscreen mode Exit fullscreen mode

Here we have a member which is a struct with three fields: id, img and desc all of which are defined as strings while this works and the code compiles there are a few issues:

  • All three fields have type string, but they most likely expect different formats.

  • One can assign any arbitrary string to any of the fields (which is very easy to do by mistake).

  • You might need to validate incoming data in more than one place before creating a member struct.

That piece of code is a classic example of a code "smell" known as primitive obsession, code smells are abstract signs or symptoms of software rot, primitive obsession is one of them, in a small application or a simple command line tool a struct def like that is fine but in a bigger program (think tens of thousands of lines of code) it becomes a real issue.

we can add types that help express our intentions better:

type member struct {
    id   uuidStr
    img  urlStr
    desc text150
}

type (
  uuidStr string
  urlStr  string
  text150 string
)
Enter fullscreen mode Exit fullscreen mode

We defined three new types: uuidStr, urlStr and text150 all of them have string as their underlying types, our member struct fields describe the string format they use better.

we can take this further and create maker functions:

func makeUuidStr(rawId string) (uuidStr, error) {
  if !canBeUUID(rawId) {
      return "", fmt.Errorf("\"%s\" is not a valid UUID string", rawId)
  }

  return uuidStr(rawId), nil
}

func makeUrlStr(rawURL string) (urlStr, error) {
  if !canBeURL(rawURL) {
      return "", fmt.Errorf("\"%s\" is not a valid URL", rawURL)
  }

  return urlStr(rawURL), nil
}

func makeText150(rawText string) (text150, error) {
  if len(rawText) > 150 {
    return "", fmt.Errorf("\"%s\" is over 150 bytes long!", rawText[:25])
  }

  return text150(rawText), nil
}
Enter fullscreen mode Exit fullscreen mode

With the help of these functions we can then write a constructor for member:

func createMember(rawId, rawImgURL, rawDesc string) (member, error) {
  id, idErr := makeUuidStr(rawId)
  img, imgErr := makeUrlStr(rawImgURL)
  desc, descErr := makeText150(rawDesc)

  if idErr != nil {
    return member{}, idErr
  } else if imgErr != nil {
    return member{}, imgErr
  } else if descErr != nil {
    return member{}, descErr
  }

  return member{id, img, desc}, nil
}
Enter fullscreen mode Exit fullscreen mode

This may seem like a lot of work, but we've essentially made sure that our data is validated at construction time, we don't need to do any validation after that unless we want to update a field, our member will always be in a valid state, with that said there are things to take into account:

  • If you plan to use custom types instead of primitives you need to use their constructors, and not use type casting or untyped constants as a quick and dirty way to work around the validation.

  • To fix primitive obsession you traditionally would use classes (there would be a UUID class, a URL class, a Text class ... and so on) in go we don't have those, but some people would model uuidStr as a struct, with one or more methods to unwrap the string value, this complicates our design but it provides better protection against bugs, for example you can still do things like slicing or indexing a uuidStr or even concatenate two uuidStr values together.

Discussion (0)