DEV Community

Mohamed Edrah
Mohamed Edrah

Posted on

Go: Making state explicit using the type system

In the last post we learned how to create self documenting data types, we used the type system to create data types that would be in a constant valid state (because of validation on construction and when updating the value), now we're going types to more easily express states that our data could be in.

Consider the following types:

type chatroom struct {
    id      uuidStr
    name    nameStr
    members membersList
}

type membersList []member

type member struct {
    id   uuidStr
    role membership
}

type membership int

const (
    owner membership = iota
    removed
    banned
    regular
)
Enter fullscreen mode Exit fullscreen mode

Most of them are self explanatory the one we're interested in is member because depending on the value of role field the other data inside a member might be interpreted differently and require us to preserve different invariants, this is a pretty common situation we say that member has state because how we process member differs due to the value of one or more of the fields.

This simple example doesn't do this problem justice, imagine a much bigger data structure like a shipping order for example you'd probably have a lot of fields with different data filled at different times depending on the state of the order, at best you'd probably have an enum to tell what state the order is in or at worst you'd have a few boolean flags for each state.

Generally speaking there are two ways to fix this problem:

  • The functional way
  • The object oriented way

The functional way

In functional programming data is just data, nothing more nothing less.

All objects in our programs have a type, and a type is a name for a set of values, a type can be either a product type (sometimes known as a composite, or record type) or it can be a sum type (sometimes known discriminated union, OR type, choice type)

product and sum types (RUST)

// Sum type
enum AwesomeFruit {
    Apple,
    Banana,
    Orange,
}

// Product type
struct Fruit {
    name: String,
    calories: i32,
}
Enter fullscreen mode Exit fullscreen mode

In a functional language you'd declare a choice type, create a few objects with it and then when it's time to preform some data processing, we use pattern matching to match the the objects against type patterns like this:

matching an object of choice type against patterns (RUST)

match apple {
    AwesomeFruit::Apple => println!("You created an apple"),
    AwesomeFruit::Banana => println!("You created a banana"),
    AwesomeFruit::Orange => println!("You created an Orange"),
    };
Enter fullscreen mode Exit fullscreen mode

The compiler will refuse to compile code if we don't handle all the possible permutations (or at least provide a default catch all handler, you can do that in rust with _ => println!(""))

Choice types are pretty powerful, sadly go doesn't support sum types (although there's current debate over adding some form of support for them), most go code out there is written in a OOP/imperative style, but don't let that discourage you there's a lot of people that write functional programs in go, functional go code is idiomatic.

Even though go doesn't have choice types, or pattern matching, there are multiple ways of faking them and we're going to look at the most common way which involves modeling the choice type as an interface type and have the variants/permutations of the type be objects that implement that interface:

type member interface{ member() }

type owner   struct{ id uuidStr }
type banned  struct{ id uuidStr }
type removed struct{ id uuidStr }
type regular struct{ id uuidStr }

func (*owner) member() {}
func (*banned) member() {}
func (*removed) member() {}
func (*regular) member() {}
Enter fullscreen mode Exit fullscreen mode

We can then make constructors for each of the variants

func makeOwner(id uuidStr) member {
    return &owner{id}
}
func makeRegular(id uuidStr) member {
    return &regular{id}
}
func makeBanned(id uuidStr) member {
    return &banned{id}
}
func makeRemoved(id uuidStr) member {
    return &removed{id}
}
Enter fullscreen mode Exit fullscreen mode

And whenever we have a member object we can "match" against it using a type switch:

switch v := m.(type) {
    case owner:
    // Do stuff for the owner
    case regular:
    // Do stuff for the regular
    case banned:
    // Do stuff for the banned
    case removed:
    // Do stuff for the removed

    default:
      panic("missing handling for " + fmt.Sprintf("%T"))
}
Enter fullscreen mode Exit fullscreen mode

This mimics pattern matching, but it has some important downsides to consider:

  • This isn't pattern matching and isn't as powerful as pattern matching, also the compiler won't check that you've handled all the variants in the type switch.
  • The default guard above will only be triggered in runtime causing a panic, panicking is an anti-pattern in go and even in functional programming.

We can fix these two problems by relying on static analyzers such as go-sumtypes

The object oriented way

In object oriented programming (unlike functional programming) data is protected & encapsulated and is grouped with public behavior that mutates the data.

To deal with an object that has many possible states, we use the strategy pattern, this pattern allows an object to easily have different behaviors by extracting the API of the required behaviors into a class or interface and then have implementations inherit that class or interface and implement the methods, we can then dynamically load instances of the subclass at runtime and use them.

You probably already used the strategy pattern, it's pretty common in go!

f, err := os.Open("foo")
if err != nil {
    return err
}
defer f.Close()

sink := make([]byte, 250)
defer.Read(sink)
Enter fullscreen mode Exit fullscreen mode

*os.File implements the io.Reader interface, which only has one method called Read any object that implements Read can be used as an io.Reader.

in a similar fashion we can create objects that implement a member interface and then define whatever services we want from a member on it

type interface member {
    canSendMsg() bool
}

type banned struct {}
func (*banned) canSendMsg() bool { return false }

type removed struct{}
func (*removed) canSendMsg() bool { return false }

type owner struct {}
func (*owner) canSendMsg() bool { return true }

type regular struct{}
func (*regular) canSendMsg() bool { return true }
Enter fullscreen mode Exit fullscreen mode

You'll see people use the OOP solution more often, and it's a lot more common in the standard library too, so learning how to use polymorphism and other OOP stuff is a must for any golang dev even you want to code in a functional style.

Top comments (0)