Hacker News new | ask | show | jobs
by stiiv 770 days ago
As someone who values a tight domain model (a la DDD) and primarily writes TypeScript, I've considered introducing branded types many times, and always decline. Instead, we just opt for "aliases," especially of primatives (`type NonEmptyString = string`), and live with the consequences.

The main consequence is that we need an extra level of vigilance and discipline in PR reviews, or else implicit trust in one another. With a small team, this isn't difficult to maintain, even if it means that typing isn't 100% perfect in our codebase.

I've seen two implementations of branded types. One of them exploits a quirk with `never` and seems like a dirty hack that might no longer work in a future TS release. The other implementation is detailed in this article, and requires the addition of unique field value to objects. In my opinion, this pollutes your model in the same way that a TS tagged union does, and it's not worth the trade-off.

When TypeScript natively supports discriminated unions and (optional!) nominal typing, I will be overjoyed.

2 comments

Can you say more about natively supporting discriminated unions?

You can already do this:

    type MyUnion = { type: "foo"; foo: string } | { type: "bar"; bar: string };
And this will compile:

    (u: MyUnion) => {
      switch (u.type) {
        case "foo":
          return u.foo;
        case "bar":
          return u.bar;
      }
    };
Whereas this wont:

    (u: MyUnion) => {
      switch (u.type) {
        case "foo":
          return u.bar;
        case "bar":
          return u.foo;
      }
    };
Sure! You need a `type` field (or something like it) in TS.

You don't need that in a language like F# -- the discrimation occurs strictly in virtue of your union definition. That's what I meant by "native support."

Aren't these two forms isomorphic:

    type MyUnion = { type: "foo"; foo: string } | { type: "bar"; bar: string };
vs

    type MyUnion = Foo of { foo: string } | Bar of { bar: string };
You still need some runtime encoding of which branch of the union your data is; otherwise, your code could not pick a branch at runtime.

There's a slight overhead to the TypeScript version (which uses strings instead of an int to represent the branch) but it allows discriminated unions to work without having to introduce them as a new data type into JavaScript. And if you really wanted to, you could use a literal int as the `type` field instead.

Isn’t it the same in TypeScript? You don’t need an explicit type field.
It depends on what you're trying to achieve. If there are sufficient structural differences, you're fine (`"foo" in myThing` can discrimate) but if two types in your union have the same structure, TS doesn't give you a way to tell them apart. (This relates back to branded types.)

A good example would be `type Money = Dollars | Euros` where both types in the union alias `number`. You need a tag. In other languages, you don't.

True, although I think that's just missing branded types, not discriminated unions.
> The other implementation is detailed in this article, and requires the addition of unique field value to objects.

That's not quite what ends up happening in this article though. The actual objects themselves are left unchanged (no new fields added), but you're telling the compiler that the value is actually an intersection type with that unique field. There a load-bearing `as Hash` in the return statement of `generateHash` in the article's example that makes it work without introducing runtime overhead.

I definitely agree about native support for discriminated unions / nominal typing though, that would be fantastic.

Big thank you for clarifying -- I missed that. This approach is far less unsavory that some other attempts that I've seen.