Skip to content(if available)orjump to list(if available)

Use Your Type System

Use Your Type System

340 comments

·July 24, 2025

pclowes

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.

josephg

Yep. For this reason, I wish more languages supported bound integers. Eg, rather than saying x: u32, I want to be able to use the type system to constrain x to the range of [0, 10).

This would allow for some nice properties. It would also enable a bunch of small optimisations in our languages that we can't have today. Eg, I could make an integer that must fall within my array bounds. Then I don't need to do bounds checking when I index into my array. It would also allow a lot more peephole optimisations to be made with Option.

Weirdly, rust already kinda supports this within a function thanks to LLVM magic. But it doesn't support it for variables passed between functions.

mcculley

Ada has this ability to define ranges for subtypes. I wish language designers would look at Ada more often.

tylerhou

Academic language designers do! But it takes a while for academic features to trickle down to practical languages—especially because expressive-enough refinement typing on even the integers leads to an undecidable theory.

fny

Range checks in Ada are basically assignment guards with some cute arithmetic attached. Ada still does most of the useful checking at runtime, so you're really just introducing more "index out of bounds". Consumer this example:

procedure Sum_Demo is subtype Index is Integer range 0 .. 10; subtype Small is Integer range 0 .. 10;

   Arr : array(Index) of Integer := (others => 0);
   X : Small := 0;
   I : Integer := Integer'Value(Integer'Image(X));  -- runtime evaluation
begin for J in 1 .. 11 loop I := I + 1; end loop;

   Arr(I) := 42;  -- possible out-of-bounds access if I = 11
end Sum_Demo;

This compile, and the compiler will tell you: "warning: Constraint_Error will be raised at run time".

It's a stupid example for sure. Here's a more complex one:

    procedure Sum_Demo is
       subtype Index is Integer range 0 .. 10;
       subtype Small is Integer range 0 .. 10;
 
       Arr : array(Index) of Integer := (others => 0);
       X : Small := 0;
       I : Integer := Integer'Value(Integer'Image(X));  -- runtime evaluation
    begin
       for J in 1 .. 11 loop
          I := I + 1;
       end loop;
 
       Arr(I) := 42;  -- Let's crash it
    end Sum_Demo;

This again compiles, but if you run it: raised CONSTRAINT_ERROR : sum_demo.adb:13 index check failed

It's a cute feature, but it's useless for anything complex.

pjmlp

And I wish people would remember Pascal and Modula-2 had it before Ada. :)

jjmarr

VHDL has this feature too, being based on Ada.

nikeee

I proposed a primitive for this in TypeScript a couple of years ago [1].

While I'm not entirely convinced myself whether it is worth the effort, it offers the ability to express "a number greater than 0". Using type narrowing and intersection types, open/closed intervals emerge naturally from that. Just check `if (a > 0 && a < 1)` and its type becomes `(>0)&(<1)`, so the interval (0, 1).

I also built a simple playground that has a PoC implementation: https://nikeee.github.io/typescript-intervals/

[1]: https://github.com/microsoft/TypeScript/issues/43505

mnahkies

Related https://github.com/microsoft/TypeScript/issues/54925

My specific use case is pattern matching http status codes to an expected response type, and today I'm able to work around it with this kind of construct https://github.com/mnahkies/openapi-code-generator/blob/main... - but it's esoteric, and feels likely to be less efficient to check than what you propose / a range type.

There's runtime checking as well in my implementation, but it's a priority for me to provide good errors at build time

archargelod

Nim[0] supports subrange types:

  type
    Foo = range[1 .. 10]
    Bar = range[0.0 .. 1.0] # float works too
  
  var f:Foo = 42       # Error: cannot convert 42 to Foo = range 1..10(int)
  var p = Positive 22  # Positive and Natural types are pre-defined
[0] - https://nim-lang.org/docs/manual.html#types-subrange-types

steveklabnik

In my understanding Rust may gain this feature via “pattern types.”

ijustlovemath

Where can I sign?

librasteve

in raku, that’s spelled

  subset OneToTen of Int where 1..10:

tmtvl

In Common Lisp it's

  (deftype One-To-Ten ()
    '(Integer 1 10))

yoz-y

Off topic: do you use raku in day to day life? I tried learning it but perl5 remains my go-to when I just need to whip something up

fny

But wouldn't that also require code execution? For example even though the compiler already knows the size of an array and could do a bounds check on direct assigment (arr[1] = 1) in some wild nested loop you could exceed the bounds that the compiler can't see.

Otherwise you could have type level asserts more generally. Why stop at a range check when you could check a regex too? This makes the difficulty more clear.

For the simplest range case (pure assignment) you could just use an enum?

bakul

Pascal had range types such as 0..9 (as of 1970). Subranges could also be defined for any scalar type. Further, array index types were such ranges.

someone_19

You can do this quite easily in Rust. But you have to overload operators to make your type make sense. That's also possible, you just need to define what type you get after dividing your type by a regular number and vice versa a regular number by your type. Or what should happen if when adding two of your types the sum is higher than the maximum value. This is quite verbose. Which can be done with generics or macros.

josephg

You can do it at runtime quite easily in rust. But the rust compiler doesn’t understand what you’re doing - so it can’t make use of that information for peephole optimisations or to elide array bounds checks when using your custom type. And you don’t get runtime errors instead of compile time errors if you try to assign the wrong value into your type.

arrowsmith

FYI: Ruby is strongly typed, not loosely.

    > 1 + "1"
    (irb):1:in 'Integer#+': String can't be coerced into Integer (TypeError)
     from (irb):1:in '<main>'
     from <internal:kernel>:168:in 'Kernel#loop'
     from /Users/george/.rvm/rubies/ruby-3.4.2/lib/ruby/gems/3.4.0/gems/irb-1.14.3/exe/irb:9:in '<top (required)>'
     from /Users/george/.rvm/rubies/ruby-3.4.2/bin/irb:25:in 'Kernel#load'
     from /Users/george/.rvm/rubies/ruby-3.4.2/bin/irb:25:in '<main>'

kitten_mittens_

There seem to be two competing nomenclatures around strong/weak typing where people mean static/dynamic instead.

josephg

Some people mistakenly call dynamic typing "weak typing" because they don't know what those words mean. PSA:

Static typing / dynamic typing refers to whether types are checked at compile time or runtime. "Static" = compile time (eg C, C++, Rust). "Dynamic" = runtime (eg Javascript, Ruby, Excel)

Strong / weak typing refers to how "wibbly wobbly" the type system is. x86 assembly language is "weakly typed" because registers don't have types. You can do (more or less) any operation with the value in any register. Like, you can treat a register value as a float in one instruction and then as a pointer during the next instruction.

Ruby is strongly typed because all values in the system have types. Types affects what you can do. If you treat a number like its an array in ruby, you get an error. (But the error happens at runtime because ruby is dynamically typed - thus typechecking only happens at runtime!).

ameliaquining

I recall a type theorist once defined the terms as follows (can't find the source): "A strongly typed language is one whose type system the speaker likes. A weakly typed language is one whose type system the speaker dislikes."

Related Stack Overflow post: https://stackoverflow.com/questions/2690544/what-is-the-diff...

So yeah I think we should just give up these terms as a bad job. If people mean "static" or "dynamic" then they can say that, those terms have basically agreed-upon meanings, and if they mean things like "the type system prohibits [specific runtime behavior]" or "the type system allows [specific kind of coercion]" then it's best to say those things explicitly with the details filled in.

jnpnj

yes, untyped names != untyped objects

pclowes

Oops, I meant weakly typed as in JS or strongly typed as in Ruby. But decided to switch the Ruby example to Elixir and messed up the sentence

js2

Good luck with this fight. I've had it on HN most recently 7 months ago, but about Python:

https://news.ycombinator.com/item?id=42367644

A month before that:

https://news.ycombinator.com/item?id=41630705

I've given up since then.

9rx

We've been reading comments like that since the internet was created (and no doubt in books before that). Why give up now?

johnfn

    irb(main):001:0> a = 1
    => 1
    irb(main):002:0> a = '1'
    => "1"
It doesn't seem that strong to me.

folkrav

It would be weak if that was actually mutating the first “a”. That second declaration creates a new variable using the existing name “a”. Rust lets you do the same[1].

[1] https://doc.rust-lang.org/book/ch03-01-variables-and-mutabil...

null

[deleted]

0x457

These are two entirely different a's you're storing reference to it in the same variable. You can do the same in rust (we agree it statically and strongly typed, right?):

let a = 1;

let a = '1';

Strongly typing means I can do 1 + '1' variable names and types has nothing to do with it being strongly typed.

dgb23

In the dynamic world being able to redefine variables is a feature not a bug (unfortunately JS has broken this), even if they are strongly typed. The point of strong typing is that the language doesn't do implicit conversions and other shenanigans.

9rx

The types are strong. The variables are weak.

Spivak

Well yeah, because variables in what you consider to be a strongly typed language are allocating the storage for those variables. When you say int x you're asking the compiler to give you an int shaped box. When you say x = 1 in Ruby all you're doing is saying is that in this scope the name x now refers to the box holding a 1. You can't actually store a string in the int box, you can only say that from now on the name x refers to the string box.

jevndev

The “Stop at first level of type implementation” is where I see codebases fail at this. The example of “I’ll wrap this int as a struct and call it a UUID” is a really good start and pretty much always start there, but inevitably someone will circumvent the safety. They’ll see a function that takes a UUID and they have an int; so they blindly wrap their int in UUID and move on. There’s nothing stopping that UUID from not being actually universally unique so suddenly code which relies on that assumption breaks.

This is where the concept of “Correct by construction” comes in. If any of your code has a precondition that a UUID is actually unique then it should be as hard as possible to make one that isn’t. Be it by constructors throwing exceptions, inits returning Err or whatever the idiom is in your language of choice, the only way someone should be able to get a UUID without that invariant being proven is if they really *really* know what they’re doing.

(Sub UUID and the uniqueness invariant for whatever type/invariants you want, it still holds)

munificent

> This is where the concept of “Correct by construction” comes in.

This is one of the basic features of object-oriented programming that a lot of people tend to overlook these days in their repetitive rants about how horrible OOP is.

One of the key things OO gives you is constructors. You can't get an instance of a class without having gone through a constructor that the class itself defines. That gives you a way to bundle up some data and wrap it in a layer of validation that can't be circumvented. If you have an instance of Foo, you have a firm guarantee that the author of Foo was able to ensure the Foo you have is a meaningful one.

Of course, writing good constructors is hard because data validation is hard. And there are plenty of classes out there with shitty constructors that let you get your hands on broken objects.

But the language itself gives you direct mechanism to do a good job here if you care to take advantage of it.

Functional languages can do this too, of course, using some combination of abstract types, the module system, and factory functions as convention. But it's a pattern in those languages where it's a language feature in OO languages. (And as any functional programmer will happily tell you, a design pattern is just a sign of a missing language feature.)

lock1

I find regular OOP language constructor are too restrictive. You can't return something like Result<CorrectObject,ConstructorError> to handle the error gracefully or return a specific subtype; you need a static factory method to do something more than guaranteed successful construction w/o exception.

Does this count as a missing language feature by requiring a "factory pattern" to achieve that?

hombre_fatal

I don't see this having much to do with OOP vs FP but maybe the ease in which a language lets you create nominal types and functions that can nicely fail.

What sucks about OOP is that it also holds your hand into antipatterns you don't necessarily want, like adding behavior to what you really just wanted to be a simple data type because a class is an obvious junk drawer to put things.

And, like your example of a problem in FP, you have to be eternally vigilant with your own patterns to avoid antipatterns like when you accidentally create a system where you have to instantiate and collaborate multiple classes to do what would otherwise be a simple `transform(a: ThingA, b: ThingB, c: ThingC): ThingZ`.

Finally, as "correct by construction" goes, doesn't it all boil down to `createUUID(string): Maybe<UUID>`? Even in an OOP language you probably want `UUID.from(string): Maybe<UUID>`, not `new UUID(string)` that throws.

mrkeen

You have it backwards from where I'm standing.

'null' (and to a large extent mutability) drives a gigantic hole through whatever you're trying to prove with correct-by-construction.

You can sometimes annotate against mutability in OO, but even then you're probably not going to get given any persistent collections to work with.

The OO literature itself recommends against using constructors like that, opting for static factory pattern instead.

Yoric

Indeed, OOP and FP both allow and encourage attaching invariants to data structures.

In my book, that's the most important difference with C, Zig or Go-style languages, that consider that data structures are mostly descriptions of memory layout.

null

[deleted]

Tainnor

> Functional languages can do this too, of course, using some combination of abstract types, the module system, and factory functions as convention

In Haskell:

1. Create a module with some datatype

2. Don't export the datatype's constructors

3. Export factory functions that guarantee invariants

How is that more complicated than creating a class and adding a custom constructor? Especially if you have multiple datatypes in the same module (which in e.g. Java would force you to add multiple files, and if there's any shared logic, well, that will have to go into another extra file - thankfully some more modern OOP languages are more pragmatic here).

(Most) OOP languages treat a module (an importable, namespaced subunit of a program) and a type as the same thing, but why is this necessary? Languages like Haskell break this correspondence.

Now, what I'm missing from Haskell-type languages is parameterised modules. In OOP, we can instantiate classes with dependencies (via dependency injection) and then call methods on that instance without passing all the dependencies around, which is very practical. In Haskell, you can simulate that with currying, I guess, but it's just not as nice.

MoreQARespect

I've recently been following red-green-refactor but instead of with a failing test, I tighten the screws on the type system to make a production-reported bug cause the type checker to fail before making it green by fixing the bug.

I still follow TDD-with-a-test for all new features, all edge cases and all bugs that I can't trigger failure by changing the type system for.

However, red-green-refactor-with-the-type-system is usually quick and can be used to provide hard guarantees against entire classes of bug.

pclowes

I like this approach, there are often calls for increased testing on big systems and what they really mean is increased rigor. Don't waste time testing what you can move into the compiler.

It is always great when something is so elegantly typed that I struggle to think of how to write a failing test.

What drives me nuts is when there are testing left around basically testing the compiler that never were “red” then “greened” makes me wonder if there is some subtle edge case I am missing.

eyelidlessness

As you move more testing responsibilities to the compiler, it can be valuable to test the compiler’s responsibilities for those invariants though. Otherwise it can be very hard to notice when something previously guaranteed statically ceases to be.

eyelidlessness

I found myself following a similar trajectory, without realizing that’s what I was doing. For a while it felt like I was bypassing the discipline of TDD that I’d previously found really valuable, until I realized that I was getting a lot of the test-first benefits before writing or running any code at all.

Now I just think of types as the test suite’s first line of defense. Other commenters who mention the power of types for documentation and refactoring aren’t wrong, but I think that’s because types are tests… and good tests, at almost any level, enable those same powers.

MoreQARespect

I dont think tests and types are the same "thing" per se - they work vastly better in conjunction with each other than alone and are weirdly symmetrical in the way that theyre bad substitutes for each other.

However, Im convinced that theyre both part of the same class of thing, and that "TDD" or red/green/refactor or whatever you call it works on that class, not specifically just on tests.

Documentation is a funny one too - I use my types to generate API and other sorts of reference docs and tests to generate how-to docs. There is a seemingly inextricable connection between types and reference docs, tests and how-to docs.

tossandthrow

This can usually be alleviated by structural types instead of nominal types.

You can always enforce nominal types if you really need it.

reactordev

Union types!! If everything’s a type and nothing works together, start wrapping them in interfaces and define an über type that unions everything everywhere all at once.

Welcome to typescript. Where generics are at the heart of our generic generics that throw generics of some generic generic geriatric generic that Bob wrote 8 years ago.

Because they can’t reason with the architecture they built, they throw it at the type system to keep them in line. It works most of the time. Rust’s is beautiful at barking at you that you’re wrong. Ultimately it’s us failing to design flexibility amongst ever increasing complexity.

Remember when “Components” where “Controls” and you only had like a dozen of them?

Remember when a NN was only a few hundred thousand parameters?

As complexity increases with computing power, so must our understanding of it in our mental model.

However you need to keep that mental model in check, use it. If it’s typing, do it. If it’s rigorous testing, write your tests. If it’s simulation, run it my friend. Ultimately, we all want better quality software that doesn’t break in unexpected ways.

valenterry

union types are great. But alone they are not sufficient for many cases. For example, try to define a datastructure that captures a classical evaluation-tree.

You might go with:

    type Expression = Value | Plus | Minus | Multiply | Divide;
    
    interface Value    { type: "value"; value: number; }
    interface Plus     { type: "plus"; left: Expression; right: Expression; }
    interface Minus    { type: "minus"; left: Expression; right: Expression; }
    interface Multiply { type: "multiply"; left: Expression; right: Expression; }
    interface Divide   { type: "divide"; left: Expression; right: Expression; }
And so on.

That looks nice, but when you try to pattern match on it and have your pattern matching return the types that are associated with the specific operation, it won't work. The reason is that Typescript does not natively support GADTs. Libs like ts-pattern use some tricks to get closish at least.

And while this might not be very important for most application developers, it is very important for library authors, especially to make libraries interoperable with each other and extend them safely and typesafe.

kazinator

Also known as "make bad state unexperimentable".

atoav

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)

abraxas

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

bcrosby95

I think most complaints about checked exceptions in Java ultimately boil down to how verbose handling exceptions in Java is. Everytime the language forces you to handle an exception when you don't really need to makes you hate it a bit more.

First, the library author cannot reasonably define what is and isn't a checked exception in their public API. That really is up to the decision of the client. This wouldn't be such a big deal if it weren't so verbose to handle exceptions though: if you could trivially convert an exception to another type, or even declare it as runtime, maybe at the module or application level, you wouldn't be forced to handle them in these ways.

Second, to signature brittleness, standard advice is to create domain specific exceptions anyways. Your code probably shouldn't be throwing IOExceptions. But Java makes converting exceptions unnecessarily verbose... see above.

Ultimately, I love checked exceptions. I just hate the ergonomics around exceptions in Java. I wish designers focused more on fixing that than throwing the baby out with the bathwater.

lock1

If only Java also provided Either<L,R>-like in the standard library...

Personally I use checked exceptions whenever I can't use Either<> and avoid unchecked like a plague.

Yeah, it's pretty sad Java language designer just completely deserted exception handling. I don't think there's any kind of improvement related to exceptions between Java 8 and 24.

alex_smart

Ok please help me understand, what is the difference between - R method() throws L, and - Either<L, R> method()

To me they seem completely isomorphic?

vips7L

That’s the same conclusion I’ve come too. I’ve commented on it a little bit here:

https://news.ycombinator.com/item?id=44551088

https://news.ycombinator.com/item?id=44432640

> Your code probably shouldn't be throwing IOExceptions. But Java makes converting exceptions unnecessarily verbose

The problem just compounds too. People start checking things that they can’t handle from the functions they’re calling. The callers upstream can’t possibly handle an error from the code you’re calling, they have no idea why it’s being called.

I also hate IOException. It’s so extremely unspecific. It’s the worst way to do exceptions. Did the entire disk die or was the file not just found or do I not have permissions to write to it? IOException has no meaning.

Part of me secretly hopes Swift takes over because I really like its error handling.

samus

There usually are more specific exceptions, at least when it's easy enough to distinguish the root cause from OS APIs. But it often isn't. A more practical concern is that it is not always easy to find out which type it is. The identity of the specific types might not be part of the public API interface, perhaps intentionally so.

default-kramer

I think checked exceptions were maligned because they were overused. I like that Java supports both checked and unchecked exceptions. But IMO checked exceptions should only be used for what Eric Lippert calls "exogenous" exceptions [1]; and even then most of them should probably be converted to an unchecked exception once they leave the library code that throws them. For example, it's always possible that your DB could go offline at any time, but you probably don't want "throws SQLException" polluting the type signature all the way up the call stack. You'd rather have code assuming all SQL statements are going to succeed, and if they don't your top-level catch-all can log it and return HTTP 500.

[1] https://ericlippert.com/2008/09/10/vexing-exceptions/

materiallie

Put another way: errors tend to either be handled "close by" or "far away", but rarely "in the middle".

So Java's checked exceptions force you to write verbose and pointless code in all the wrong places (the "in the middle" code that can't handle and doesn't care about the exception).

Jensson

> So Java's checked exceptions force you to write verbose and pointless code in all the wrong places (the "in the middle" code that can't handle and doesn't care about the exception).

It doesn't, you can just declare that the function throws these as well, you don't have to handle it directly.

abraxas

It's fine to let exceptions percolate to the top of the call stack but even then you likely want to inform the user or at least log it in your backend why the request was unsuccessful. Checked exceptions force both the handling of exceptions and the type checking if they are used as intended. It's not a problem if somewhere along the call chain an SQLException gets converted to "user not permitted to insert this data" exception. This is how it was always meant to work. What I don't recommend is defaulting to RuntimeException and derivatives for those business level exceptions. They should still be checked and have their own types which at least encourages some discipline when handling and logging them up the call stack.

yardstick

In my experience, the top level exception handler will catch all incl Throwable, and then inspect the exception class and any nested exception classes for things like SQL error or MyPermissionsException etc and return the politically correct error to the end user. And if the exception isn’t in a whitelist of ones we don’t need to log, we log it to our application log.

null

[deleted]

codr7

Sometimes I feel like I actually wouldn't mind having any function touching the database tagged as such. But checked exceptions are such a pita to deal with that I tend to not bother.

alex_smart

>you probably don't want "throws SQLException" polluting the type signature all the way up the call stack

A problem easily solved by writing business logic in pure java code without any IO and handling the exceptions gracefully at the boundary.

Jtsummers

Setting aside the objections some have to exceptions generally: Checked exceptions, in contrast to unchecked, means that if a function/method deep in your call stack is changed to throw an exception, you may have to change many function (to at least denote that they will throw that exception or some exception) between the handler and the thrower. It's an objection to the ergonomics around modifying systems.

Think of the complaints around function coloring with async, how it's "contagious". Checked exceptions have the same function color problem. You either call the potential thrower from inside a try/catch or you declare that the caller will throw an exception.

gpderetta

And as with async, the issue is a) the lack of the ability to write generic code that can abstract over the async-ness or throw signature of a function and b) the ability to type erase asyncness (by wrapping with stackful coroutines) or throw signature (by converting to unchecked exceptions).

Incidentally, for exceptions, Java had (b), but for a long time didn't have (a) (although I think this changed?), leading to (b) being abused.

abraxas

That's a valid point but it's somewhere on a spectrum of "quick to write/change" vs "safe and validated" debate of strictly vs loosely typed systems. Strictly typed systems are almost by definition much more "brittle" when it comes to code editing. But the strictness also ensures that refactoring is usually less perilous than in loosely typed code.

bigstrat2003

> Checked exceptions, in contrast to unchecked, means that if a function/method deep in your call stack is changed to throw an exception, you may have to change many function (to at least denote that they will throw that exception or some exception) between the handler and the thrower.

That's the point! The whole reason for checked exceptions is to gain the benefit of knowing if a function starts throwing an exception that it didn't before, so you can decide how to handle it. It's a good thing, not a bad thing! It's no different from having a type system which can tell you if the arguments to a function change, or if its return type does.

Jtsummers

Why are you screaming? All those wasted exclamation marks, you could have written something I didn't know. I didn't say it wasn't the point or that it was a bad thing.

someone_19

Unhappy way is a part of contract. So yes, that is what I want. If a function couldn't fail before but can after the update - I want to know about it.

In fact, at each layer, if you want to propagate an error, you have to convert it to one specific to that layer.

jayd16

C# went with properly typed but unchecked exceptions. IMO it gives you a clean error stacks without too much of an issue.

I also think its a bit cleaner to have a nicely pattern matched handler blocks than bespoke handling at every level. That said, if unwrapped error results have a robust layout then its probably pretty equivalent.

pjmlp

Until routinely prod goes down with that exception that no one cared to handle, been there done that.

jayd16

For something like a web request, all the frameworks catch the exception at the request level. Prod should not be going down.

Maybe you mean requests are failing on uncaught exceptions, in which case I'd say it's working well.

dherls

With Java, there are a lot of usability issues with checked types. For example streams to process data really don't play nicely if your map or filter function throws a checked exception. Also if you are calling a number of different services that each have their own checked exception, either you resort to just catching generic Exception or you end up with a comically large list of exceptions

hiddew

That is why I am happy that rich errors (https://xuanlocle.medium.com/kotlin-2-4-introduces-rich-erro...) are coming to Kotlin. This expresses the possible error states very well, while programming for the happy path and with some syntactic sugar for destucturing the errors.

wvenable

I rarely have more than handful of try..catch blocks in any application. These either wrap around an operation that can be retried in the case of temporary failure or abort the current operation with a logged error message.

Checked exceptions feel like a bad mix of error returns and colored functions to me.

Hackbraten

For anyone who dislikes checked exceptions due to how clunky they feel: modern Java allows you to construct custom Result-like types using sealed interfaces.

taeric

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. :(

hombre_fatal

Maybe I know what you mean.

There are cases where you have the data in hand but now you have to look for how to create or instantiate the types before you can do anything with it, and it can feel like a scavenger hunt in the docs unless there's a cookbook/cheatsheet section.

One example is where you might have to use createVector(x, y, z): Vector when you already have { x, y, z }. And only then can you createFace(vertices: Vector[]): Face even though Face is just { vertices }. And all that because Face has a method to flip the normal or something.

Another example is a library like Java's BouncyCastle where you have the byte arrays you need, but you have to instantiate like 8 different types and use their methods on each other just to create the type that lets you do what you wish was just `hash(data, "sha256")`.

Maxion

This stuff gets unbearable very fast. We have custom types for geometries at my work. We also use a bunch of JS libraries for e.g. coordinate conversions. They output as [number, number, number], whereas our internal type are number[].

Cthulhu_

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.

theshrike79

Also because of the Go type system if a thing fills a specific interface, it can be used instead of said interface, making things a bit less strict.

chriswarbo

"Phantom types" are useful for what you describe: that's where we add a parameter to a type (i.e. making it generic), but we don't actually use that parameter anywhere. I used this when dealing with cryptography in Scala, where everything is just an array of bytes, but phantom types prevented me getting them mixed up. https://news.ycombinator.com/item?id=28059019

stellalo

Ideally though, the compiler lowers all domain specific logic into simple byte-moving, just after having checked that types add up. Or maybe I misunderstood what you meant?

recursivedoubts

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

tshaddox

> 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.

Presumably you need to know what an Account and a User are to use that software in the first place. I can't imagine a reasonable person easily understanding a getAccountById function which takes one argument of type UUID, but having trouble understanding a getAccountById function which takes one argument of type AccountId.

kjksf

UserID and AccountID could just as well be integers.

What he means is that by introducing a layer of indirection via a new type you hide the physical reality of the implementation (int vs. string).

The physical type matters if you want to log it, save to a file etc.

So now for every such type you add a burden of having to undo that indirection.

At which point "is it worth it?" is a valid question.

You made some (but not all) mistakes impossible but you've also introduced that indirection that hides things and needs to be undone by the programmer.

yawaramin

Well...yeah. That's the point. We want there to be a layer of indirection to prevent mistakes. Otherwise you get things like this: https://www.columbia.edu/~ng2573/zuggybuggy_is_2scale4ios.pd...

> There is a UI for memorialising users, but I assured her that the pros simply ran a bit of code in the PHP debugger. There’s a function that takes two parameters: one the ID of the person being memorialised, the other the ID of the person doing the memorialising. I gave her a demo to show her how easy it was....And that’s when I entered Clowntown....I first realised something was wrong when I went back to farting around on Facebook and got prompted to login....So in case you haven’t guessed what I got wrong yet, I managed to get the arguments the wrong way round. Instead of me memorialising my test user, my test user memorialised me.

paulddraper

I recommend adding a serialization method to your types, namely to text, but optionally to JSON as well.

Mawr

> 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.

spion

The OP is the author of grugbrain.dev

buerkle

foo(UUID, UUID); foo(AccountId, UserId);

I'd much rather deal with the 2nd version than the first. It's self-documenting and prevents errors like calling "foo(userId, accountId)" letting the compiler test for those cases. It also helps with more complex data structures without needing to create another type.

  Map<UUID, List<UUID>>
  Map<AccountId, List<UserId>>

ElectricalUnion

> I know what a UUID (or a String) is.

I now know I never know whenever "a UUID" is stored or represented as a GUIDv1 or a UUIDv4/UUIDv7.

I know it's supposed to be "just 128 bits", but somehow, I had a bunch of issues running old Java servlets+old Java persistence+old MS SQL stack that insisted, when "converting" between java.util.UUID to MS SQL Transact-SQL uniqueidentifier, every now and then, that it would be "smart" if it flipped the endianess of said UUID/GUID to "help me". It got to a point where the endpoints had to manually "fix" the endianess and insert/select/update/delete for both the "original" and the "fixed" versions of the identifiers to get the expected results back.

(My educated guess it's somewhat similar to those problems that happens when your persistence stack is "too smart" and tries to "fix timezones" of timestamps you're storing in a database for you, but does that wrong, some of the time.)

paulddraper

UUIDs all have the same storage+representation.

They are generated with different algorithms, if you find these distinctions to be semantically useful to operations, carry that distinction into the type.

Seems like 98% of the time it wouldn’t matter.

3836293648

To be fair, you probably needed to know that anyway? Or else you would've just passed invalid data into functions.

recursivedoubts

I cannot recall ever passing an invalid UUID (or long id) into a function due to statically-knowable circumstances.

happytoexplain

The point is that you might pass a semantically invalid user ID. Not that you might pass an invalid UUID.

I generally agree that it's easy to over-do, but can be great if you have a terse, dense, clear language/framework/docs, so you can instantly learn about UserID.

spion

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.

dgb23

I think the example is just not very useful, because it illustrates a domain separation instead of a computational one, which is almost always the wrong approach.

It is however useful to return a UUID type, instead of a [16]byte, or a HTMLNode instead of a string etc. These discriminate real, computational differences. For example the method that gives you a string representation of an UUID doesn't care about the surrounding domain it is used in.

Distinguishing a UUID from an AccountID, or UserID is contextual, so I rather communicate that in the aggregate. Same for Celsius and Fahrenheit. We also wouldn't use a specialized type for date times in every time zone.

petesergeant

> 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.

Yes, that’s exactly the point. If you don’t know how to acquire an AccountID you shouldn’t just be passing a random string or UUID into a function that accepts an AccountID hoping it’ll work, you should have acquired it from a source that gives out AccountIDs!

recursivedoubts

And that's my point: I'm usually getting AccountIDs from strings (passed in via HTTP requests) so the whole thing becomes a pointless exercise.

Kranar

You just accept raw strings without doing any kind of validation? The step that performs validation should encode that step in the form of a type.

lmm

If your system is full of stringly typed network interfaces then yes there is no point in trying to make it good. You can make things a bit better by using a structured RPC protocol like gRPC, but the only real solution is to not do that.

petesergeant

Do you validate them? I assume you do. Feels like a great time to cast them too

socalgal2

My team recently did this to some C++ code that was using mixed numeric values. It started off as finding a bug. The bug was fixed but the fixer wanted to add safer types to avoid future bugs. They added them, found 3 more bugs where the wrong values were being used unintentionally.

kwon-young

This reminds me of the mp-units [1] library which aims to solve this problem focusing on the physical quantities. The use of strong quantities means that you can have both safety and complex conversion logic handled automatically, while having generic code not tied to single set of units.

I have tried to bring that to the prolog world [2] but I don't think my fellow prolog programmers are very receptive to the idea ^^.

[1] https://mpusz.github.io/mp-units/latest/

[2] https://github.com/kwon-young/units

ryandrake

I remember a long, long time ago, working on a project that handled lots of different types of physical quantities: distance, speed, temperature, pressure, area, volume, and so on. But they were all just passed around as "float" so you'd every so often run into bugs where a distance was passed where a speed was expected, and it would compile fine but have subtle or obvious runtime defects. Or the API required speed in km/h, but you passed it miles/h, with the same result. I always wanted to harden it up with distinct types so we could catch these problems during development rather than testing, but I was a junior guy and could never articulate it well and justify the engineering effort, and nobody wanted to go through the effort of explicitly converting to/from primitive types to operate on the numbers.

mabster

I had kind of written off using types because of the complexity of physical units, so I will be having a look at that!

My biggest problem has been people not specifying their units. On our own code end I'm constantly getting people to suffix variables with the units. But there's still data from clients, standard library functions, etc. where the units aren't specified!

tyleo

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.

default-kramer

I've done something like that too. I also noticed that enums are even lower-friction (or were, back in 2014) if your IDs are integers, but I never put this pattern into real code because I figured it might be too confusing: https://softwareengineering.stackexchange.com/questions/3090...

gpderetta

FWIW, I extensively use strong enums in C++[1] for exactly this reason and they are a cheap simple way to add strongly typed ids.

[1] enum class from C++11, classic enums have too many implicit conversions to be of any use.

TuxSH

> classic enums have too many implicit conversions

They're fairly useful still (and since C++11 you can specify their underlying type), you can use them as namespaced macro definitions

Kinda hard to do "bitfield enums" with enum class

TuxSH

> classic enums have too many implicit conversions

They're fairly useful still (and since C++11 you can specify their underlying type), you can use them as namespaced macro definitions

yawaramin

This technique is called 'phantom type' because no values of MFoo or MBar exist at runtime.

SideburnsOfDoom

There are libraries for that, such as Vogen https://github.com/SteveDunn/Vogen

The name means "Value Object Generator" as it uses Source generation to generate the "Value object" types.

That readme has links to similar libraries and further reading.

tyleo

This seems like overkill. I’d prefer the few lines of code above to a whole library.

SideburnsOfDoom

Is it "overkill" if it's already written and tested?

Once you have several of these types, and they have validation and other concerns then the cost-benefit might flip.

FYI, In modern c#, you could try using "readonly record struct" in order to get lots of equality and other concerns generated for you. It's like a "whole library" but it's a compiler feature.

rjbwork

Have you used this in production? It seems appealing but seems so anti-thetical to the common sorts of engineering cultures I've seen where this sort of rigorous thinking does not exactly abound.

vborovikov

Source generators hide too many details from the user.

I prefer to have the generated code to be the part of the code repo. That's why I use code templates instead of source generators. But a properly constructed ID type has a non-trivial amount of code: https://github.com/vborovikov/pwsh/blob/main/Templates/ItemT...

null

[deleted]

SideburnsOfDoom

Sadly I have not. I have played with it and it seems to hold up quite well.

I want it for a case where it seems very well suited - all customer ids are strings, but only very specific strings are customer ids. And there are other string ids around as well.

IMHO Migration won't be hard - you could allow casts to/from the primitive type while you change code. Temporarily disallowing these casts will show you where you need to make changes.

I don't know yet how "close to the edges" you would have to go back to the primitive types in ordered for json and db serialisation to work.

But it would be easier to get in place in a new "green field" codebase. I pitched it as a refactoring, but the other people were well, "antithetical" is a good word.

mcflubbins

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.

peterldowns

My friend Lukas has written about this before in more detail, and describes the general technique as "Safety Through Incompatibility". I use this approach in all of my golang codebases now and find it invaluable — it makes it really easy to do the right thing and really hard to accidentally pass the wrong kinds of IDs around.

https://lukasschwab.me/blog/gen/deriving-safe-id-types-in-go...

https://lukasschwab.me/blog/gen/safe-incompatibility.html

frankus

Swift has a typealias keyword but it's not really useful for this since two distinct aliased types with the same underlying type can be freely interchanged. Wrong code may look wrong but it will still compile.

Wrapper structs are the idiomatic way to achieve this, and with ExpressibleByStringLiteral are pretty ergonomic, but I wonder if there's a case for something like a "strong" typealias ("typecopy"?) that indicates e.g. "this is just a String but it's a particular kind of String and shouldn't be mixed with other Strings".

titanomachy

Yeah, most languages I've used are like this. E.g. rust/c/c++.

I guess the examples in TFA are golang? It's kind of nice that you don't have to define those wrapper types, they do make things a bit more annoying.

In C++ you have to be extra careful even with wrapper classes, because types are allowed to implicitly convert by default. So if Foo has a constructor that takes a single int argument, then you can pass an int anywhere Foo is expected. Fine as long as you remember to mark your constructors as explicit.

ghosty141

Rust has the newtype idiom which works as proper type alias most of the time

ameliaquining

Both clang-tidy and cpplint can be configured to require all single-argument constructors (except move, copy, and initializer-list constructors) to be marked explicit, in order to avoid this pitfall.

dataflow

This sounds elegant in theory but very thorny in practice even with a standards change, at least in C++ (though I don't believe the issues are that particular to the language). Like how do you want the equivalent of std::cout << your_different_str to behave? What about with third-party functions and extension points that previously took strings?

jandrewrogers

Isn't that where C++20 concepts come in?

qcnguy

Haskell has this, it's called newtype.

In OOP languages as long as the type you want to specialize isn't final you can just create a subclass. It's cheap (no additional wrappers or boxes), easy, and you can specialize behavior if you want to.

Unfortunately for various good reasons Java makes String final, and String is one of the most useful types to specialize on.

buerkle

But then you are representing two distinct types as the same underlying type, String.

  MyType extends String;
  void foo(String s);
  foo(new MyType()); // is valid
Leading to the original problem. I don't want to represent MyType as a String because it's not.

qcnguy

It has to work that way or else you can't use the standard library. What you want to block is not:

    StringUtils.trim(String foo);
but

    myApp.doSomething(AnotherMyType amt);
The latter is saying "I need not any string but a specific kind of string".

ameliaquining

In what precise way are you envisioning that this would be different from a wrapper struct?

frankus

Pretty much only less boilerplate. Definitely questionable if it's worth the added complexity. And also it could probably be a macro.

MantisShrimp90

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.

dajonker

A lot of us programmer folk are indefinitely in search of that one thing that will finally let us write the perfect, bug-free, high performance software. We take these concepts to the extreme and convince ourselves that it will absolutely work as long as we strictly do it the Right Way and only the Right Way. Then we try to convince to our fellow programmers that the Right Way will solve all of our problems and that it is the Only Way. It will be great, it will be grand, it will be amazing.

rmunn

A wise person once told me that if you ever find yourself saying "if only everyone would just do X...", then you should stop right there. Never, ever, in the history of the world has everyone done X. No matter how good an idea X is, there will always be some people who say "No, I'm going to do Y instead." Maybe they're stupid, maybe they're evil, maybe they're just ignorant... or maybe, just maybe, X was not the best thing for their particular needs and Y was actually better for them.

This is an important concept to keep in mind. It applies to programming, it applies to politics, it applies to nearly every situation you can think of. Any time you find yourself wishing that everyone would just do X and the world would be a better place, realize that that is never going to happen, and that some people will choose to do Y — and some of them will even be right to do so, because you do not (and cannot) know the specific needs of every human being on the planet, so X will not actually be right for some of them.

Mawr

> 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.

bbkane

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).

alphazard

This is a little misleading. Go will automatically convert a numeric literal (which is a compile time idea not represented at runtime) into the type of the variable it is being assigned to.

Go will not automatically cast a variable of one type to another. That still has to be done explicitly.

  func main() {
    var x int64 = 1
    Func(SpecialInt64(x)) // this will work
    Func(x) // this will not work
  }

  type SpecialInt64 int64

  func Func(x SpecialInt64) {
  }
https://go.dev/play/p/4eNQOJSmGqD

drpixie

Arrggg- that's the best reason, so far, to avoid Go.

Almost nothing is a number. A length is not a number, an age is not a number, a phone number is not a number - sin(2inches) is meaningless, 30years^2 is meaningless, phone#*2 is meaningless, and 2inches+30years is certainly meaningless - but most of our languages permit us to construct, and use, and confuse these meaningless things.

skybrian

This only happens for literal values. Mixing up variables of different types will result in a type error.

When you write 42 in Go, it’s not an int32 or int64 or some more specific type. It’s automatically inferred to have the correct type. This applies even for user-defined numeric types.

mattbee

Yep in the same way it would allow `var u userID = 1` it allows `Work(1)` rather than insisting on `var u userID = userID(1)` and `Work(userID(1))`.

I teach Go a few times a year, and this comes up a few times a year. I've not got a good answer why this is consistent with such an otherwise-explicit language.

Mawr

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.