6 ms·
This is why I tend to like to use strong type systems where possible and necessary. struct LoginUser { name: UserName, email: Email,
by atoav 3y ago
This is why I tend to like to use strong type systems where possible and necessary.
struct LoginUser {
name: UserName,
email: Email,
phone_number: Option<PhoneNumber>,
}
First the user type should reflect the function of the user. This is one that is allowed to log in (of course overly simplified). You can then also have a OnboardingUser, a ArchivedUser and so on. And if you want to transition one between those different types you need to ensure the fields are there. This is easy to test and clear.
The other thing is to use types for fields that have hard verification requirements. You can still have them behave mostly like strings for reading, but you can ensure that an Email in a LoginUser never contains an unverified Email, by first verifying the email string in the OnboardingUser and only converting it into the Email type once it is guaranteed to be a valid email. Because you use a special type tacking on further things like storing a date when this verification happened last is easy, just put it into the type as well.
Lastly the phone number is an Optional type that wraps a PhoneNumber type. This way it can either be None or Some<PhoneNumber> (this uses the Rust typesystem, but the principle would work elsewhere), the phone number should be verified the same way, ideally relying on a well tested method that also fixes the formatting at the same time.
Now in Rust if some part of the system didn't handle a user that has a phoneNumber your code won't compile, because from the standpoint of the old code this would now be an entirely new, unknown type never seen before. Sure you could step through your code and explicitly not handle it on purpose because you want back to a running system fast, fail to add a TODO and then later forget about having to do it, but that would be entirely on you.
Now those hard typesystem constraints become even more valuable when dealing with external systems. Your type should represent all assumptions you are making about the data and test those on each import. Of course you then will have import failures if the import source changed, but we want that to fail if it changes. What we don't want is the thing silently chugging the input into the wrong pipe or invalid phone numbers to be stored because the source system decided to split them up into the prefix and the rest.
- yen223 3y agoStatic types are really good for describing invariants, I definitely agree. But it does also describe a lot of implicit invariants. The LoginUser you've defined has the implicit invariant that a LoginUser only has 3 fields. It is very possible for someone to write a function that relies on a LoginUser only ever having 3 fields, which then breaks if you ever extended LoginUser. This is similar to the example that the article mentions at the start, where a thing that someone assumed would always fit in one memory region no longer did, and that caused all kinds of problems. Those kinds of implicit invariants are the ones that are tricky to make explicit, even in strongly-typed languages.
- saghm 3y ago> But it does also describe a lot of implicit invariants. The LoginUser you've defined has the implicit invariant that a LoginUser only has 3 fields. It is very possible for someone to write a function that relies on a LoginUser only ever having 3 fields, which then breaks if you ever extended LoginUse Rust does actually have a good way of dealing with this specific invariant. If you mark a struct with the `non_exhaustive` attribute[0], trying to construct it with a literal will fail outside of the package that defines it (even if all fields are marked as public), and trying to pattern match it outside the package that defines it is required to use the operator to ignore any missing fields. Not trying to say that it's possible to encode every possible invariant in Rust, but I do find it useful to leverage all of the tools available, and I've found this one super useful (on both structs and enums) despite not being super well known from what I can tell. [0]: https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute https://doc.rust-lang.org/reference/attributes/type_system.h...
- valenterry 3y agoI think that's a wrong assumption on your side. You are assuming that the type describes the "actual thing". But there is no such actual thing really. All we do is model. And here we model a view of the user within a certain context. Totally possible that a different department will model their user in a different way. This isn't even a technical problem, it's a semantical problem. Trying to reuse the same type throughout the whole world is doomed to fail, no matter what techniques you use.
- atoav 3y agoThis is good advice. If you take your models of the world too seriously chances are you are in the process of programming yourself into a corner, as things get more complex. Getting attached to your classes by writing a single superclass of The User™ is a sure way of missing out on the benefits of the typesystem and create coupling where there should be none. Knowing where to couple things and where to decouple things is one of those hard programming issues, but one that might be even more important than naming things.
- 3y ago
- cryptonector 3y agoUsing "optional" is not really enough for struct type extensions. You really want something more akin to ASN.1's extensibility markers, or typed-holes, or class derivation, etc.