I have developed some opinions about cases in which type safety can be overkill, and I wanted to share with the community and hear what other people think! I’m specifically talking about the use of custom types to add meaning to values (like using CustomerId
and OrderId
to distinguish one int from another) or to enforce validity (like using a smart constructor to restrict anything with an Email
type to be a valid email), and not the absence of types altogether.
I would love to hear dissenting opinions or more cases in which you might just reach for a primitive value rather than try to give everything a meaningful type in your application.
Types bring safety and soundness to applications. They are powerful tools to manage complexity and enforce data integrity. But they do carry a cost: it takes careful thought and non-trivial code to create custom types, and the benefits are not always worth the price. There are some cases in which custom types may be the correct solution, but for various reasons, it is acceptable to reach for primitive types instead.
You know a value is only valid within some constraints, but the type is not the right place to enforce those constraints.
This situation occurs when you know the constraints that a value is supposed to satisfy, but if it doesn’t satisfy them you aren’t allowed to reject or alter it. It’s not your call to make.
For example, you might know that a bio is only valid if it is less than or equal to 300 characters. But you only receive bios via API calls and the backend promises that all bios have been validated. If somehow an invalid one slipped through, then you are supposed to just try and use it anyway. What should you do?
In this case, forcing any Bio
type to be a string that satisfies the 300 character limit is unacceptable. You have two choices: trust your backend at the risk of accepting strings that are not actually valid, or adjust your type to account for both valid and invalid bios. Which you should choose will often depend on whether…
Enforcing constraints is not worth the effort
Some guarantees are more important than others. Guaranteeing that a payment includes a valid dollar amount is more likely to be critical than guaranteeing that all bios in the system are within 300 characters.
If you need to secure and validate all data in the system and enforce constraints with the compiler, then you should make everything type-safe. But quite often these requirements are fuzzy, and you’re required to handle inputs even when they don’t fit the spec, and so on. In these cases the imprecision of a simple primitive type may be fine.
Choosing this path has the opposite effect of restricting the domain, however. Instead, you’re expanding the domain of functions over this value to include anything that the primitive you choose could be! For a String
, that’s essentially infinite. Functions operating on these values are now going to have to handle the potential of bad input in order to maintain some semblance of integrity in your system. That means a lot more functions returning Maybe
and Either
values and pushing the responsibility to handle failure deeper into your system.
The far extreme of laziness is to not even try to handle the failure cases at all. For example, you might just represent a bio as a String
and attempt to render it out to the page no matter how long it is, or if its empty, or full of strange unicode symbols, and just accept that it will look absolutely terrible.
If you’re confident that the data is valid coming in and you can’t change it even if its invalid, then this may simply be a trade-off your company is willing to make.
The type isn’t informative on its own, but it’s part of a larger type that is informative.
CustomerId
and OrderId
newtypes can be used to distinguish two Int
values from one another. This is a fantastic way to give primitive types a little more meaning. But it isn’t always necessary. Consider a record type that contains a user’s public profile information:
type UserProfile =
{ ...
, following :: Boolean
}
This type records some data about a user, including whether you follow them. Should we create a custom type so that we don’t mix up this boolean with some other boolean?
I wouldn’t. It’s a field in a record, so you already know quite a lot about it: it has a name, following
, and belongs to a larger UserProfile
type. Most likely it will be accessed with dot syntax, like user.following
, which provides more context vs. being an isolated boolean value. I am also assuming the value is used infrequently and primarily to control the display of a button on a user profile.
We need this value to be correct, but we have plenty of information beyond the type with which to identify it and distinguish it from other boolean values.