Intro
Mixing up the values passed to functions and structs are a common mistake in many programming languages. This happens even more often with languages that have dynamic type system.
Here is a trivial example which illustrates this problem:
|
|
In this post we leverage the system to handle these kind of bugs and see which other advantages this approach offers. Don’t worry we will not go fully function or include any haskell in this post.
Custom Types
In dynamic languages, we rely on unit tests to catch type-related errors. This often leads to a substantial amount of repetitive test code, including mocks, to ensure type safety.
However, statically typed languages offer a more elegant solution by introducing a distinct type for each value with a unique meaning. This effectively shifts the responsibility of the type checking from unit tests to the compiler.
|
|
By defining custom types in the first statement, it is impossible to mix up UserName
, Email
and Password
, unless you explicitly cast the values.
This approach enhances the readability by documenting the meaning of the value and traces all occurrences of the types throughout the code. Additionally, it promotes consistent naming of values in the code. In some context you might name the UserName
type an Name
like in the struct above. But the type makes it clear we are talking about a UserName
.
Helper Methods
In Go we can define helper methods for the custom types which are only relevant to the type. Since they are methods they are easier to discover using the IDE and the code gets more organized as a result.
In this example we can avoid leaking the password in the logging. We also define a helper method on Email
.
|
|
By adding a String()
method to Password
the password will be printed as ***
obfuscating the value. The MailServer()
helper method allows you to extract the mail server from the Email address.
Validation of Custom Types
Instead of directly constructing custom types we can use constructors, similar to structs. These constructors can enforce the validation of the type and return an error when invalid data is provided. This catches errors early on, preventing hard to debug errors.
|
|
Enforcing Data Validation when Serializing
With data validation in place, we can further enhance the data integrity by catching invalid data during deserialization. This ensures no invalid data will be processed by the rest of the system.
In Go, we can define a custom JSON deserializer for our custom types. By using our constructor inside this deserializer we can handle the error during deserialization.
|
|
In this example, the func (self *Email) UnmarshalJson(data []byte) error
method is implemented which performs the deserialization fo the Email
type.
The main advantage is that we can catch error at the start and don’t have to trace back where the invalid value was created. Making the application more robust.
Type tagging
A more advanced problem is shown in the following example:
|
|
In the above example we are reusing the same type but for different things. This makes sense because both the Prod and Test Environment have dependencies and lock files. We could follow our earlier advice and make for each instance a Custom Type and just copy past functionality of that type. But this makes the code hard to maintain.
|
|
We could just handle it at runtime by introducing an ’enum’ value which we then add as a ’tag’ to each type which has a specific context:
|
|
But now we have to check this enum at runtime to make sure we have a dependency/ requirement pair with the same enum value.
If only we could use a similar trick but fully at compile time to avoid a faulty program. Lucky for us with generics this is possible.
Context dependent types
First we want to define the enum above in a way that the compiler understands it.
This is possible by creating an Environment
as a context type and Test/Prod as its instance types.
|
|
Then just like the enum example we start tagging each type which is context dependent with a tag. This we can do by introducing a generic parameter with the type Environment
.
|
|
Now we can add a type tag onto the LockFile and RequirementsFile to make it possible to add the tag to the value when we read in the file.
|
|
To make sure keep the context when resolving the function we add a type tag to the ResolveDependencies function which now passes the tag to the Dependencies type:
|
|
Now the last piece of the puzzle to solve the mistake we had before is to add a type tag to the UpdateLockFile`` function. Now we can only call
UpdateLockFile[Test]which then accepts
RequirementsFile[Test]and
LockFile[Test]`. Making sure we don’t mix up the types:
|
|
The advantage of this solution is that the compiler will keep track of the tag for us. Unlike the enum solution we will catch bugs at compile time and remove the overhead of runtime checks. Compared to the custom type solution we can now reuse our types directly.
One disadvantage is that any function which deals with a tagged type has to be context aware and thus have a generic parameter.
Context specific functions
Now we can take it a step further just like the helper methods for a custom type. We can add specialized functions which are only valid for in a specific context.
|
|
The Container[T Enviroment]
shows how easy it is to define a new context dependent variable and extend the existing system.
The RunTests
function only takes Container in the Test Environment. This makes it impossible to pass a container without the tests dependencies to the test function.
The Publish
function can only publish the container with the Prod
context avoiding us to accidentally publish the a ‘Test container’.
So now we extended both contexts with a ‘context dependent function’ which cannot be used by the other context.
Here is are some other examples on how we can share 1 function between 2 context but not 3.
|
|
Extensibility
It is rather easy to introduce another context by doing:
|
|
Now we can reuse all existing functions but have a new context with its own context specific functions.
Conclusion
Custom types and type tagging are powerful tools to make sure the compiler/type system catches any mistake we make at compile time. The added overhead of maintaining these types is more than made up by the time not spend on writing unit tests, thus speeding up our development cycle.
Custom Types:
- Allow you to define each separate value with its own type. Avoiding you to pass the wrong value to the wrong type.
- Helper methods allow you to map from one type to another custom type.
- Helper methods give you type specific operations which are only relevant to that type.
- Constructors allow you to set constraints to the values of that Type avoiding having invalid values.
- The value can also be validated during deserialization catching invalid values as soon as they enter the program.
Type Tagging
- Type tagging avoids mixing up the same value but in a different context.
- Allows for code reusing for common types. Instead of creating a type for each value + context combination.
- You can also limit certain functions to certain contexts.
- Type tagging is ‘Free’ since we never instantiate a context value.
Custom types are easy to rollout and can catch most of the type errors by making sure each value has its own specific type. It also gives a lot of added benefits which are easy to implement in go.
Type tagging is more advanced and you have to be sure that this is the right solution for the problem. But it has as an advantage that you can reuse more code and don’t have to introduce as many types as when you use Custom Types.