71 comments

[ 11.0 ms ] story [ 1546 ms ] thread
I generally agree, but I think the real strength in types come from the way in which they act as documentation and help you refactor. If you see a well laid out data model in types you supercharge your ability to understand a complex codebase. Issues like the one in the example should have been caught by a unit test.
I like this. Very much falls into the "make bad state unrepresentable".

The issues I see with this approach is when developers stop at this first level of type implementation. Everything is a type and nothing works well together, tons of types seem to be subtle permutations of each other, things get hard to reason about etc.

In systems like that I would actually rather be writing a weakly typed dynamic language like JS or a strongly typed dynamic language like Elixir. However, if the developers continue pushing logic into type controlled flows, eg:move conditional logic into union types with pattern matching, leverage delegation etc. the experience becomes pleasant again. Just as an example (probably not the actual best solution) the "DewPoint" function could just take either type and just work.

As you mentioned correctly: if you go for strongly typed types in a library you should go all the way. And that means your strong type should provide clear functions for its conversion to certain other types. Some of which you nearly always need like conversion to a string or representation as a float/int.

The danger of that is of course that you provide a ladder over the wall you just built and instead of

   temperature_in_f = temperature_in_c.to_fahrenheit()
They now go the shortcut route via numeric representation and may forget the conversion factor. In that case I'd argue it is best to always represent temperature as one unit (Kelvin or Celsius, depending on the math you need to do with it) and then just add a .display(Unit:: Fahrenheit) method that returns a string. If you really want to convert to TemperatureF for a calculation you would have to use a dedicated method that converts from one type to another.

The unit thing is of course an example, for this finished libraries like pythons pint (https://pint.readthedocs.io/en/stable/) exist.

One thing to consider as well is that you can mix up absolute values ("it is 28°C outside") and temperature deltas ("this is 2°C warmer than the last measurement"). If you're controlling high energy heaters mixing those up can ruin your day, which is why you could use different types for absolutes and deltas (or a flag within one type). Datetime libraries often do that as well (in python for example you have datetime for absolute and timedelta for relative time)

Does anyone know the term for this? I had "Type Driven Development" in my head, but I don't know if that's a broadly used term for this.

It's a step past normal "strong typing", but I've loved this concept for a while and I'd love to have a name to refer to it by so I can help refer others to it.

Does this apply in Java where adding a type means every ID has to have a class instance in the heap? ChatGPT says I might want to wait for Project Valhalla value types.
Highly recommend the Early Access book Data-Oriented Programming with Java by Chris Kiehl as another resource.
This works very well and I'd whish I'd convince my team members to use more this technique.

Moreover: you can separate types based on admitted values and perform runtime checks. Percentage, Money, etc.

This pattern is exactly the pattern I recommended two weeks ago in a thread about a nearly catastrophic OpenZFS bug https://news.ycombinator.com/item?id=44531524 in response to someone saying we should use AI to detect this class of bugs. I'm glad there are still people who think alike and opt for simpler, more deterministic solutions such as using the type system.
Hard not to agree with the general idea. But also hard to ignore all of the terrible experiences I've had with systems where everything was a unique type.

In general, I think this largely falls when you have code that wants to just move bytes around intermixed with code that wants to do some fairly domain specific calculations. I don't have a better way of phrasing that, at the moment. :(

The article is written in Go, in which - iirc - it's fairly easy and cheap to convert a type alias back to its original type (e.g. an AccountID to an int).

Using the right architecture, you could make it so your core domain type and logic uses the strictly typed aliases, and so that a library that doesn't care about domain specific stuff converts them to their higher (lower?) type and works with that. Clean architecture style.

Unfortunately, that involves a lot of conversion code.

Type systems, like any other tool in the toolbox, have an 80/20 rule associated with them. It is quite easy to overdo types and make working with a library extremely burdensome for little to no to negative benefit.

I know what a UUID (or a String) is. I don't know what an AccountID, UserID, etc. is. Now I need to know what those are (and how to make them, etc. as well) to use your software.

Maybe an elaborate type system worth it, but maybe not (especially if there are good tests.)

https://grugbrain.dev/#grug-on-type-systems

> I know what a UUID (or a String) is. I don't know what an AccountID, UserID, etc. is.

It's literally the opposite. A string is just a bag of bytes you know nothing about. An AccountID is probably... wait for it... an ID of an Account. If you have the need to actually know the underlying representation you are free to check the definition of the type, but you shouldn't need to know that in 99% of contexts you'll want to use an AccountID in.

> Now I need to know what those are (and how to make them, etc. as well) to use your software.

You need to know what all the types are no matter what. It's just easier when they're named something specific instead of "a bag of bytes".

> https://grugbrain.dev/#grug-on-type-systems

Linking to that masterpiece is borderline insulting. Such a basic and easy to understand usage of the type system is precisely what the grug brain would advocate for.

There are a few languages where this is not too tedious (although other things tend to be a bit more tedious than needed in those)

The main problem with these is how do you actually get the verification needed when data comes in from outside the system. Check with the database every time you want to turn a string/uuid into an ID type? It can get prohibitively expensive.

I'm not familiar with Go. Please correct me, but this reads like object oriented programming i.e. OOP for every kind of data?

Coming from C++, this kind of types with classes make sense. But also are a maintenance task with further issues, were often proper variable naming matters. Likely a good balance is the key.

I was doing this and used it for a year in https://github.com/bbkane/warg/, but ripped it out since Go auto-casts underlying types to derived types in function calls:

    Type userID int64

    func Work(u userID) {...}

    Work(1) // Go accepts this
I think I recalled that correctly. Since things like that were most of what I was doing I didn't feel the safety benefit in many places, but had to remember to cast the type in others (iirc, saving to a struct field manually).
In order to run into this "issue" you'd have to do exactly what you did here - pass in a literal "1" to a function that expects a userID. That is not an issue of any sort.
Unfortunately, this can be somewhat awkward to implement in certain structural typed languages like TypeScript. I often find myself writing something along the lines of

  type UserID = string & { readonly __tag: unique symbol }
which always feels a bit hacky.
I've actually seen this before and didn't realize this is exactly what the goal was. I just thought it was noise. In fact, just today I wrote a function that accepted three string arguments and was trying to decide if I should force the caller to parse them into some specific types, or do so in the function body and throw an error, or just live with it. This is exactly the solution I needed (because I actually don't NEED the parsed values.)

This is going to have the biggest impact on my coding style this year.

An adjacent point is to use checked exceptions and to handle them appropriate to their type. I don't get why Java checked exceptions were so maligned. They saved me so many headaches on a project where I forced their use as I was the tech lead for it. Everyone hated me for a while because it forced them to deal with more than just the happy path but they loved it once they got in the rhythm of thinking about all the exceptional cases in the code flow. And the project was extremely robustness even though we were not particularly disciplined about unit testing
Checked exceptions were a reasonable idea, but the Java library implementation & use of these was totally wrong.

Checked exceptions work well for occasional major "expectable" failures -- opening a file, making a network connection.

They work extremely poorly when required for ongoing access or use of IO/ network resources, since this forces failures which are rare & impossible to usefully recover from to be explicitly declared/ caught/ rethrown with great verbosity and negative value added.

All non-trivial software is composition, so the idea of calling code "recovering" from a failure is at odds with encapsulation. What we end up with is business logic which can fail anywhere, can't recover anything, yet all middle layers -- not just the outer transaction boundary -- are forced to catch or declare these exceptions.

Requiring these "technical exceptions" to be pervasively handled is thus not just substantially invalid & pointless, but actually leads to serious rates of faulty error-handling. Measured experience in at least a couple of large codebases is that about 5-10% of catch clauses have fucked implementations either losing the cause or (worse) continue execution with null or erroneous results.

https://literatejava.com/exceptions/checked-exceptions-javas...

In C#, I often use a type like:

  readonly struct Id32<M> {
    public readonly int Value { get; }
  }
Then you can do:

  public sealed class MFoo { }
  public sealed class MBar { }
And:

  Id32<MFoo> x;
  Id32<MBar> y;
This gives you integer ids that can’t be confused with each other. It can be extended to IdGuid and IdString and supports new unique use cases simply by creating new M-prefixed “marker” types which is done in a single line.

I’ve also done variations of this in TypeScript and Rust.

Im on the opposite extreme here in that I believe typing obsession is the root of much of our problems as an industry.

I think Rich Hickey was completely right, this is all information and we just need to get better at managing information like we are supposed to.

The downside of this approach is that these systems are tremendously brittle as changing requirements make you comfort your original data model to fit the new requirements.

Most OOP devs have seen atleast 1 library with over 1000 classes. Rust doesn't solve this problem no matter how much I love it. Its the same problem of now comparing two things that are the same but are just different types require a bunch of glue code which can itself lead to new bugs.

Data as code seems to be the right abstraction. Schemas give validation a-la cart while still allowing information to be passed, merged, and managed using generic tools rather than needing to build a whole api for every new type you define in your mega monolith.

> Its the same problem of now comparing two things that are the same but are just different types require a bunch of glue code which can itself lead to new bugs.

Uhuh, so my age and my weight are the same (integers), but just have different types. Okay.

This technique makes me sad.

Not because it's a bad idea. Quite the contrary. I've sung the praises of it myself.

But because it's like the most basic way you can use a type system to prevent bugs. In both the sense used in the article, and in the sense that it is something you have to do to get the even more powerful tools brought to bear on the problem that type systems often.

And yet, in the real world, I am constantly explaining this to people and constantly fighting uphill battles to get people to do it, and not bypass it by using primitives as much as possible then bashing it into the strict type at the last moment, or even just trying to remove the types.

Here on HN we debate the finer points of whether we should be using dependent typing, and in the real world I'm just trying to get people to use a Username type instead of a string type.

Not always. There are some exceptions. And considered over my entire career, the trend is positive overall. But there's still a lot of basic explanations about this I have to give.

I wonder what the trend of LLM-based programming will result in after another few years. Will the LLMs use this technique themselves, or will people lean on LLMs to "just" fix the problems from using primitive types everywhere?

Yeah I agree with wvenable. It's definitely safer but most languages have very poor support for doing it ergonomically. In fact I have yet to find one that makes it genuinely nice.
Most static type systems that I know of disappear at runtime. You literally cannot "use" them once deployed to production.

(Typescript's Zed and Clojure's Malli are counterexamples. Although not official offerings)

Following OP's example, what prevents you from getting a AccountID parsed as a UserID at runtime, in production? In production it's all UUIDs, undistinguishable from one another.

A truly safe approach would use distinct value prefixes – one per object type. Slack does this I believe.

I've seen experienced programmers do this a lot. It's the kind of thing that someone thinks is annoying, without realizing that it was preventing them from doing something incorrect.
Complex types don't exist. Schemas do.

There is no duck, just primitive types organized duck-wise.

The sooner you embrace the truth of mereological nihilism the better your abstractions will be.

Almost everything at every layer of abstraction is structure.

Understanding this will allow you to still use types, just not abuse them because you think they are "real".

Arguably, you can get rid of "primitive types" entirely. xtc-lang's "Turtles type system" does so, where all the built-in types are defined in the standard library, where the definitions are infinitely recursive, but we have a fixpoint which we can use as if it were "primitive".

> The Ecstasy type system is called the Turtles Type System, because the entire type system is bootstrapped on itself, and -- lacking primitives -- solely on itself. An Int, for example, is built out of an Array of Bit, and a Bit is built out of an IntLiteral (i.e. 0 or 1), which is built out of a String, which is an Array of Char, and a Char is built out of an Int. Thus, an Int is built out of many Ints. It's turtles, the whole way down.

[1]:https://xtclang.blogspot.com/2019/06/an-introduction-to-ecst...

Primitive object types in Java (String, Float, …) are final. That blocks you from doing such tricks, as far as I understand.