Strong types and testing
This is reimplementation of Haskell code from bitemyapp, which itself was inspired by levinotik. Obviously Go has much simpler type system and some of the constructs in Haskell are not possible to express in it. The most important of those are sum types and purity. Yet it is still possible to express quite a lot in Go.
bitemyapp starts with declaring simplest structure to express email, and immediately notices that having all fields to be of type string
is not the best approach. In Go such a struct would be:
type Email struct {
toAddress string
fromAddress string
emailBody string
recipientName string
}
Having separate types for each field will make it hard to make a mistake when constructing email. Just like in Haskell, it is easy in Go:
type (
ToAddress string
FromAddress string
EmailBody string
RecipientName string
)
type Email struct {
toAddress ToAddress
fromAddress FromAddress
emailBody EmailBody
recipientName RecipientName
}
bitemyapp tests his code in repl, which we don’t have in Go - let’s write one unit test:
func TestInitialization(t *testing.T) {
to := ToAddress("levi@startup.com")
from := FromAddress("chris@website.org")
body := EmailBody("hi!")
name := RecipientName("Levi")
/*
email := Email{
ToAddress: from,
FromAddress: to,
EmailBody: body,
RecipientName: name,
}
Error:
./email_test.go:12: cannot use from (type FromAddress) as type ToAddress in field value
./email_test.go:13: cannot use to (type ToAddress) as type FromAddress in field value
*/
_ = Email{
To: to,
From: from,
Body: body,
Recipient: name,
}
}
This is still basically the same as Haskell.
Next thing bitemyapp mentions is making this email type abstract. In Haskell that means exporting only type without any way to access its data or construct it from outside of module. Hiding constructor in Go is easy - it’s enough to change Email
to email
to makes it private. But in contrast to Haskell, in Go it would be still possible to access members of values of that type. To have complete encapsulation (control both construction and data access) we will export only interface which email will satisfy:
type email struct {
To ToAddress
From FromAddress
Body EmailBody
Recipient RecipientName
}
func (e email) ToAddress() ToAddress { return e.To }
func (e email) FromAddress() FromAddress { return e.From }
func (e email) EmailBody() EmailBody { return e.Body }
func (e email) RecipientName() RecipientName { return e.Recipient }
type Email interface {
ToAddress() ToAddress
FromAddress() FromAddress
EmailBody() EmailBody
RecipientName() RecipientName
}
Next comes smart constructor. To validate email addresses we will use net/mail
:
type (
ErrToAddressDidntParse struct{ reason error }
ErrFromAddressDidntParse struct{ reason error }
)
func (e ErrToAddressDidntParse) Error() string {
return "'To' address didn't parse: " + e.reason.Error()
}
func (e ErrFromAddressDidntParse) Error() string {
return "'From' address didn't parse: " + e.reason.Error()
}
func NewEmail(to ToAddress, from FromAddress, body EmailBody, name RecipientName) (Email, []error) {
errors := []error{}
if err := validateAddress(string(to)); err != nil {
errors = append(errors, ErrToAddressDidntParse{err})
}
if err := validateAddress(string(from)); err != nil {
errors = append(errors, ErrFromAddressDidntParse{err})
}
if len(errors) > 0 {
return nil, errors
}
return &email{To: to, From: from, Body: body, Recipient: name}, errors
}
func validateAddress(address string) error {
_, err := mail.ParseAddress(address)
return err
}
Here we start to diverge from Haskell, at least in terms of succinctness. Thanks to thy way instance of Applicative typeclass for Maybe is defined, it is possible to express smart constructor in much shorter way. Additionally since Go has no sum types we lose type information in return value, by returning list of any errors. And finally to make this as close to Haskell as possible we diverge from Go idiom of returning error value and return list of errors. Since we still have no repl, here is a test for smart constructor:
func TestSmartConstructor(t *testing.T) {
if _, err := NewEmail(ToAddress("PLAID"), FromAddress("TROLOLOL"), body, name); len(err) == 0 {
t.Fatal("Malformed email in 'To' field was not detected")
} else {
log.Println(err)
}
if _, err := NewEmail(to, from, body, name); len(err) > 0 {
t.Fatalf("Correct invocation failed: '%v'", err)
}
}
Lastly we add a way to parse email out of json. This is done with encoding/json
and whole code is available here.