53 comments

[ 3.7 ms ] story [ 121 ms ] thread
an age is a nonnegative duration where one endpoint is constrained to be a birthday. even birthday could be abstracted. couldn't a bottle of wine have an age?

this enables the query you care about ageof(person, onday)

i like to think about types, not as isomorphic to some tuple but as the package of methods they support

interfaces are more important than classes, is the pithy exposition

"Programming to an interface" is another great topic for a blog post.

But yes, you're correct that there are many different ways to model this. Which one is best for your particular use case depends on the details of the problem you're trying to solve.

I agree with thinking of exposing an interface, but all data must fit into bytes at some point. It’s best to make it impossible to make it impossible to fuck that up, and also add an interface on top to make it even harder.
An age can be negative if you are modeling a person who hasn’t been born yet. Making these simple things complicated looks like the fragile base class problem again.
(Pedantacic alert) Not born => not person. I think it is more accurate to say that age bound to a living someone in time.
What was your age in 1800? What will it be it in 2300? AssertionError.
Moving at zero => not moving. No such thing as zero velocity. Bad logic
"In 1700, I was -290 years old."
> Now we can be sure that the age for any Person will always be within the range [0, 150).

Hopefully, in the future, we'll look at this like we look at two-digit year storage or 32-bit seconds-since-the-epoch storage, and the Y2k or Y2038 problems.

My database management experience is shouting "don't store an age, store a birthday".
Age is usually considered not PII, whereas a birthday is usually considered sensitive enough it needs protecting.

Obviously you could store birth year, but that leads to other issues.

Age has to be updated annually, whilst date of birth only when the user changes it.
You can store the date they told you.
I too hope to experience this problem.
2038 is only 14 years away - and there’s plenty of 32-bit systems still around (including those using 32-bit timestamps). For example, a system I built back in 2011 which controls an Asterisk v11 box is still running, literally unmodified, in production (and it needs restarting by cron job every few days) and I suspect it’s still going to be there in 14 years’ time too.
I found it fascinating that the first signs of the problem started cropping up in 2008, because of 30-year mortgages.
This insight translates readily into machine learning in the form of "inductive bias". It is rare that I come across a model whose architecture actually bakes in the functional form (i.e. topologies and constraints) of the system being modeled. Forcing models to relearn what should be baked-in domain experience is the most common way to waste energy and training time and bloat your models' parameter count.
(Phrase originally coined by Yaron Minsky; the earliest text mention I know of is https://blog.janestreet.com/effective-ml-revisited/)

To be pedantic, "date of birth" does not actually imply "age". At the very least you need "instant of birth" instead, since dates are subject to calendars and time zones. Also Feb 30th happened in 1712 in Sweden; beware of modelling a domain so tightly that you leave some of it unmodelled (cough email-address validation regexes).

Thanks for sharing this, I was remiss in not including it.
> beware of modelling a domain so tightly that you leave some of it unmodelled (cough email-address validation regexes).

In many cases the only feasible way to model the domain with types is to cut the scope as much as possible.

> Also Feb 30th happened in 1712 in Sweden

September 1752 was even stranger, with 11 missing days:

> The Gregorian calendar reform was adopted by the Kingdom of Great Britain, including its possessions in North America (later to become eastern USA and Canada), in September 1752. As a result, the September 1752 cal shows the adjusted days missing. This month was the official (British) adoption of the Gregorian calendar from the previously used Julian calendar. This has been documented in the man pages for Sun Solaris as follows. "An unusual calendar is printed for September 1752. That is the month when 11 days were skipped to make up for lack of leap year adjustments.

    $ cal 9 1752
      September 1752
     S  M Tu  W Th  F  S
           1  2 14 15 16
    17 18 19 20 21 22 23
    24 25 26 27 28 29 30
https://en.wikipedia.org/wiki/Cal_(command)#Quirks_(1752)
I always thought data storage methods where every state is valid were cool - ie. so that I could feed /dev/random into them and it would spit out something. It's even cooler if every valid output has precisely one encoding.

Huffman or arithmetic encoded data is one example of this. And formats that build off that (eg. most video formats) can decode random data as long as you put the right headers on, sometimes making beautiful artwork.

Of course, if you’re going to constrain your data, make sure your constraints are actually correct…

  case February => assert(value >= 1 && value <= 28)

Last time I checked, 2024 is a leap year, so Feb 29 is valid…
Nice work! Now solve the other problem at the end of the article
Is it the fact that you can still mark the DOB as a day later in the current year, which would make age negative?
That's another flaw in that model, definitely.
Constrained types would be nice. And you can write a complex thing in most languages, but I think often you just end up with a bunch of Builder code to enforce some invariant. And that's a nightmare. You have to generate it and stuff like that.
I agree with the title, though the age example in the article isn't great.

One of the things I see often in code reviews is the wrong use of product types where sum type would be better. Things like returning error and value separately and the value is meaningless/undefined if there was an error

This is much more frequent in languages which make use of sum types hard (looking at you std:: variant) compared to ones that make it easy (like Rust). Of course Go is particularly terrible at it and stubborn in it's design, one of the main reasons I hate using it when forced to.

Do you mean that, each return type should be union'd with potential error types?

If there was an error, the value should be meaningless. I don't understand what your complaint is against returning both the value and the error.

(Python dev learning Golang, I like the "return error" idea)

In Go you rely on convention that if err != nil, the other value is meaningless and should not be used. You have to remember to check this, and some interfaces break this convention and use eg. error+some partial value, or nil+nil or other combination.

With Rust's Result<T> you either get the value, or the error. Never both or neither. Those states are simply unrepresentable. absl::StatusOr does the same in C++, with slightly worse ergonomics.

> If there was an error, the value should be meaningless. I don't understand what your complaint is against returning both the value and the error.

Think of it this way: why are you even returning a meaningless value in the first place?

More tersely, returning errors is not the problem. Returning errors and results simultaneously (and relying on context to distinguish them) is the problem. Sums are not well modelled by products.
> If there was an error, the value should be meaningless. I don't understand what your complaint is against returning both the value and the error.

What TFA says: yeah, just make invalid states unrepresentable in the type system.

No. If there was an error, there should be no value at all.
Validating ages/birthdates based on plausible human lifespans is, frankly, dumb.
What is a better way? The lifespan thing just keeps people from doing 9308029348
I would at least consider the possibility that someone being 9308029348 years old in your system probably doesn't matter.
The reason it matters is that I don't want to have to deal with it on the front end
I’m torn on this subject. I’m all for stopping dumb bugs from occurring, But I can’t help but think in the general case this blog post is advocating for putting business logic inside my data layer.

I am reminded of blog post I read some number of years ago that represented Pokémon type valances in his programs type system. Ironically, in the blog post itself there was a table showing how all the types interacted.

If your data has complicated restrictions on what is valid, I feel it’s much better to serialize those restrictions in some database/config, as opposed to mucking up the physical structure of your program. That way when the rules inevitably change, you just have to change your config instead of rework your type system or database relations.

> But I can’t help but think in the general case this blog post is advocating for putting business logic inside my data layer.

That’s not a bad thing, provided the business/domain rules are invariant.

> That way when the rules inevitably change, you just have to change your config instead of rework your type system or database relations.

This is why it makes sense to do fast-moving projects in, say, TypeScript on NodeJS (or back-in-the-day: PHP) with a denormalized object-store (e.g. JSON blobs in Redis) and lots of E2E tests - while doing legacy-business stuff in, say, .NET and RDBMS.

It’s disappointing how often people instead misinterpret the above to stereotype a platform as good or bad (well, PHP probably deserved it…). Right-tool-for-the-right-job and all.

Right tool for the job is a safe bet for and path to low, mediocre, and sometimes above average quality that has a hard ceiling.

Integration complexities can be ignored often since they are typically longer term considerations.

The answer here is to have dumb types to store the data in, smarter domain types correct by construction, and only write business logic on the domain types.
(comment deleted)
Jon Brower Minnoch (638 kg) breaks this program. If a hardcoded constraint is going to go into a program, it's basically necessary to do a bunch of expensive research on what it should be. It's much cheaper to just let the numbers be unless there's a pressing need.
Let me be the first to point out that the article introduces Contract Programming in its extreme form, without calling it by name.

This is something that Dlang supported as a compiler feature for ages now: the ability to make an assertion about the object's internal state and have the compiler validate it whenever it changes.

Having it supported by the compiler rather than kludging the same out of a type system neatly sidesteps the problems named, like the validity of one field depending on the value of another field: you'll just use an invariant on the level where it can be validated, rather than on the basic types, and you'll also be able to use plain assertions rather than type constraints.

I appreciate the general idea of using strong types, but I think the chosen example was a bit unfortunate and distracts from the actual problem. (Granted, it was a toy example, but still.)

Any system dealing with people will inevitably end up dealing with someone whose age is unknown. If the system cannot process this, these (very real) people will become "unrepresentable", with all kinds of consequences. Imagine a hypothetical story like "The hotel canceled my reservation because their system didn't have my age." Sounds familiar?

Also, ZIP code should be string, and you had better not have a type constraint for it, unless you never plan to sell anything overseas.

The principle seems reasonable, and is easily applicable for single data elements, but I feel it is a little naive and will break down with collections of data, which is where the real problems usually are anyway.
You just need the right grammar. Design a bunch of operators and operands that do exactly what you want. Make that your working medium. Translate to and from that for working with real world stuff.

It simplifies the job immensely.

This is a lot of words to say that the ideal representation of "age" is an integer.
One place I'm surprised I don't see this more is sets. In my experience arrays are used without a second thought when any collection type is needed.