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
}
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
)
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
}
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
}
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 auuidStr
or even concatenate twouuidStr
values together.
Top comments (0)