Readit News logoReadit News
bradrn · 2 years ago
In most languages, doing what this article describes is quite straightforward: you would just define a new type (/ struct / class) called ‘Hash’, which functions can take or return. The language automatically treats this as a completely new type. This is called ‘nominal typing’: type equality is based on the name of the type.

The complication with TypeScript is that it doesn’t have nominal typing. Instead, it has ‘structural typing’: type equality is based on what the type contains. So you could define a new type ‘Hash’ as a string, but ‘Hash’ would just be a synonym — it’s still considered interchangeable with strings. This technique of ‘branded types’ is simply a way to simulate nominal typing in a structural context.

beeboobaa3 · 2 years ago
> In most languages, doing what this article describes is quite straightforward

Well, no. In most languages you wind up making a typed wrapper object/class that holds the primitive. This works fine, you can just do that in TypeScript too.

The point of branded types is that you're not introducing a wrapper class and there is no trace of this brand at runtime.

DanielHB · 2 years ago
I see where you are coming from but you are not quite understanding what the OP was saying

  class A {
    public value: number
  }
  class B {
    public value: number
  }
  const x: A = new B() // no error
This is structural typing (shape defines type), if typescript had nominal typing (name defines type) this would give an error. You could brand these classes to forcefully cause this to error.

Branding makes structural typing work like nominal typing for the branded type only.

It is more like "doing what this article describes" is the default behaviour of most languages (most languages use nominal typing).

skybrian · 2 years ago
Depends what you mean by "most languages." I think it's clearer to say which languages have zero-overhead custom types.

Go has it. Java didn't used to have it so you would use wrapper classes, but I haven't kept up with Java language updates.

JonChesterfield · 2 years ago
There's never any trace of typescript types at runtime.
hn_throwaway_99 · 2 years ago
What you say is true, but after years of working with TypeScript (and about 15 years of Java before that) I'd say that from a purely practical perspective the structural typing approach is much more productive.

I still have PTSD from the number of times I had to do big, painful refactorings in Java simply because of the way strong typing with nominal types works. I still shudder to think of Jersey 1.x to 2.x migrations from many years ago (and that was a PITA for many reasons beyond just nominal typing, but it could have been a lot easier with structural types).

I love branded types (and what I think of their close cousin of string template literal types in TS) because they make code safer and much more self-documenting with minimal effort and 0 runtime overhead.

RussianCow · 2 years ago
I think proper type inference along with some sort of struct literal syntax could make a lot of these problems go away without the need for structural typing. If you can create structs without explicitly mentioning the type (as long as it can be inferred), then you can use the same syntax for different types as long as their signatures are the same. Structural typing makes it too easy to accidentally pass something that looks like a duck but is actually a goose.

As an example, this syntax should be possible without structural typing or explicitly specifying the type of the value:

    // assuming a type signature of moveToPoint(Point)
    moveToPoint({x: 5, y: 10}) // the struct literal is inferred to be a Point
I believe F# has syntax like this, but it's been a while since I've used it so I don't remember the details.

cageface · 2 years ago
Overall I do like working with Typescript and structural typing does have some real advantages but, like just about everything in engineering, there are also tradeoffs.

Without some discipline you can create some very messy type spaghetti with TS. Nominal typing is more rigid but it can also force you to think more clearly about and carefully define the different entities in your system.

afiori · 2 years ago
Typescript has good reasons to default to Structural Typing as untagged union type are one of the most used types in typing js code and Nominal Typing does not really have a good equivalent for them.
mirekrusin · 2 years ago
Nominal typing with correct variance describes OO (classes, inheritance and rules governing it <<liskov substitution principles, L from SOLID>>). Structural typing is used for everything else in js.

Flow does it correctly. Typescript treats everything as structurally typed.

As a side note flow also has first class support for opaque types so no need to resort to branding hacks.

jacobsimon · 2 years ago
You can still do this with classes in typescript:

class Hash extends String {}

https://www.typescriptlang.org/play/?#code/MYGwhgzhAEASkAtoF...

brlewis · 2 years ago
That's distinguishing the String class from primitive string. I don't think that would still work with another `extends String` the same shape as Hash.

For example: https://www.typescriptlang.org/play/?#code/GYVwdgxgLglg9mABO...

  class Animal {
    isJaguar: boolean = false;
  }

  class Automobile {
    isJaguar: boolean = false;
  }

  function engineSound(car: Automobile) {
    return car.isJaguar ? "vroom" : "put put";
  }

  console.log(engineSound(42)); // TypeScript complains
  console.log(engineSound(new Animal())); // TypeScript does not complain

yencabulator · 2 years ago
Great example of something that does not work. Javascript classes are structural by default, Typescript does nothing there.

https://www.typescriptlang.org/play/?#code/MYGwhgzhAEASkAtoF...

eru · 2 years ago
Btw, there are quite a few language that have nominal typing, but use the equivalent of structural typing in their unions.

Eg in Rust or Haskell you can distinguish `Option<Option<bool>>`, but not in these languages. I guess Python and Typescript are examples of these?

msoad · 2 years ago
It took me so long to fully appreciate TypeScript's design decision for doing structural typing vs. nominal typing. In all scenarios, including the "issue" highlighted in this article there is no reason for wanting nominal typing.

In this case where the wrong order of parameters was the issue, you can solve it with [Template Literal Types](https://www.typescriptlang.org/docs/handbook/2/template-lite...). See [1].

And for `hash.toUpperCase()`, it's a valid program. TypeScript is not designed to stop you from using string prototype methods on... strings!

It's more pronounced in object types that some library authors don't want you to pass an object that conforms to the required shape and insist on passing result of some function they provide. e.g. `mylib.foo(mylib.createFooOptions({...})`. None of that is necessary IMO

[1] https://www.typescriptlang.org/play/?#code/MYewdgzgLgBA5gUzA...

dllthomas · 2 years ago
> And for `hash.toUpperCase()`, it's a valid program.

In a sense, but it's not the program we wanted to write, and types can be a useful way of moving that kind of information around within a program during development.

> TypeScript is not designed to stop you from using string prototype methods on... strings!

No, but it is designed to let me design my types to stop myself from accidentally using string prototype methods on data to which they don't actually apply, even when that data happens to be represented as... strings.

lolinder · 2 years ago
Template literal types solve ordering for a very specific type of parameter-order problems which happens to include the (explicitly identified as an example) terrible hash function that just prepends "hashed_".

But what about when you have an actual hash function that can't be reasonably represented by a template literal type? What about when the strings are two IDs that are different semantically but identical in structure? What about wanting to distinguish feet from inches from meters?

Don't get me wrong, I like structural typing, but there are all kinds of reasons to prefer nominal in certain cases. One reason why I like TypeScript is that you can use tricks like the one in TFA to switch back and forth between them as needed!

lIIllIIllIIllII · 2 years ago
This example is also an odd choice because... it's not the right way to do it. If you're super concerned about people misusing hashes, using string as the type is a WTF in itself. Strings are unstructured data, the widest possible value type, essentially "any" for values that can be represented. Hashes aren't even strings anyway, they're numbers that can be represented as a string in base-whatever. Of course any such abstraction leaks when prodded. A hash isn't actually a special case of string. You shouldn't inherit from string.

If you really need the branded type, in that you're inheriting from a base type that does more things than your child type.... you straight up should not inherit from that type, you've made the wrong abstraction. Wrap an instance of that type and write a new interface that actually makes sense.

I also don't really get what this branded type adds beyond the typical way of doing it i.e. what it does under the hood, type Hash = string & { tag: "hash" }. There's now an additional generic involved (for funnier error messages I guess) and there are issues that make it less robust than how it sells itself. Mainly that a Branded<string, "hash"> inherits from a wider type than itself and can still be treated as a string, uppercased and zalgo texted at will, so there's no real type safety there beyond the type itself, which protects little against the kind of developer who would modify a string called "hash" in the first place.

kaoD · 2 years ago
> I also don't really get what this branded type adds beyond the typical way of doing it

Your example is a (non-working) tagged union, not a branded type.

Not sure about op's specific code, but good branded types [0]:

1. Unlike your example, they actually work (playground [1]):

  type Hash = string & { tag: "hash" }
  
  const doSomething = (hash: Hash) => true
  
  doSomething('someHash') // how can I even build the type !?!?
2. Cannot be built except by using that branded type -- they're actually nominal, unlike your example where I can literally just add a `{ tag: 'hash' }` prop (or even worse, have it in a existing type and pass it by mistake)

3. Can have multiple brands without risk of overlap (this is also why your "wrap the type" comment missed the point, branded types are not meant to simulate inheritance)

4. Are compile-time only (your `tag` is also there at runtime)

5. Can be composed, like this:

  type Url = Tagged<string, 'URL'>;
  type SpecialCacheKey = Tagged<Url, 'SpecialCacheKey'>;
See my other comment for more on what a complete branded type offers https://news.ycombinator.com/item?id=40368052

[0] https://github.com/sindresorhus/type-fest/blob/main/source/o...

[1] https://www.typescriptlang.org/play/?#code/C4TwDgpgBAEghgZwB...

IshKebab · 2 years ago
This is a bit of a nitpick because strings often aren't the most appropriate type for hashes... but in some cases I can see using strings as the best choice. It's probably the fastest option for one.

In any case it still demonstrates the usefulness of branded types.

stiiv · 2 years ago
> A runtime bug is now a compile time bug.

This isn't valuable to you? How do you get this without nominal typing, especially of primatives?

tom_ · 2 years ago
Did they edit their post? The > syntax indicates a verbatim quote here.
skybrian · 2 years ago
How do you do this with template literal types? Does that mean you changed the string that gets passed at runtime?

The nice thing about branding (or the "flavored" variant which is weaker but more convenient) is that it's just a type check and nothing changes at runtime.

jacobsimon · 2 years ago
The demo they posted demonstrates how to do it. But I don’t think it’s a generally good solution to the problem, it feels like it solves this specific case where the type is a string hash. I think the evolution of this for other types and objects is more like what the OP article suggests.

I wonder if a more natural solution would be to extend the String class and use that to wrap/guard things:

class Hash extends String {}

compareHash(hash: Hash, input: string)

Here's an example: https://www.typescriptlang.org/play/?#code/MYGwhgzhAEASkAtoF...

mattstir · 2 years ago
> In this case where the wrong order of parameters was the issue, you can solve it with Template Literal Types

You can solve the issue in this particular example because the "hashing" function happens to just append a prefix to the input. There is a lot of data that isn't shaped in that manner but would be useful to differentiate nonetheless.

> And for `hash.toUpperCase()`, it's a valid program.

It's odd to try and argue that doing uppercasing a hash is okay because the hash happens to be represented as a string internally, and strings happen to have such methods on them. Yes, it's technically a valid program, but it's absolutely not correct to manipulate hashes like that. It's even just odd to point out that Typescript includes string manipulation methods on strings. The whole point of branding like this is to treat the branded type as distinct from the primitive type, exactly to avoid this correctness issue.

treflop · 2 years ago
The real problem is that hashes as strings is wrong.

Hashes are typically numbers.

Do you store people's ages as hex strings?

Deleted Comment

vjerancrnjak · 2 years ago
One tricky scenario I stumbled on is `.toString()`.

Everything has a `.toString()` but some objects A have `.toString(arg1, arg2, arg3)`. But replacing A with something that does not have toString with arguments still type checks, yet will probably result in serious error.

Quothling · 2 years ago
This is sort of by design. Generally speaking Object.prototype.toString() does not accept parameters, I think the only "standard" implementation which takes parameters is Number.prototype.toString(). You can overwrite .toString with your customized functions, but doing so is full of risks which can basiclaly be summed up to performance overhead and unexpected behaviour due to how you're not really in control of an object's state... As you sort of point out here.

I know it's very tempting for people coming from OOP languages to use their own custom toString functions, but you really shouldn't. If you really need a string version of an object for debugging purposes you should instead get it through JSON.stringify. This is partly because .toString() isn't really meant to be used by you in JS. You can, and in a few cases it may make sense, but it's usually unnecessary because JS will do it automatically if you simply wrap your non-string primitives in the string where you want to use them.

In general it's better to work with objects directly and not think of them as "classes". I think (and this is my opinion which people are going to disagree with) in general you're far better off by very rarely using classes at all in TS. There are obviously edge cases where you're going to need classes, but for 95% of your code they are going to be very unnecessary and often make it much harder for developers who may not primarily work with JS or another weakly typed language. Part of this is because classes aren't actually classes, but mainly it's because you can almost always achieve what you want with an interface or even a Type in a manner that is usually more efficient, more maintainable and easier to test because of it's decoupled nature. I have this opinion after working with JS in both the back-end and front-end for over a decade and seeing how horrible things can go wrong because we all write shitty code on a thursday afternoon, and because JS often won't work like many people from C#, Java or similar backgrounds might expect.

kaoD · 2 years ago
> In all scenarios [...] there is no reason for wanting nominal typing.

Hard disagree.

It's very useful to e.g. make a `PasswordResetToken` be different from a `CsrfToken`.

Prepending a template literal changes the underlying value and you can no longer do stuff like `Buffer.from(token, 'base64')`. It's just a poor-man's version of branding with all the disadvantages and none of the advantages.

You can still `hash.toUpperCase()` a branded type. It just stops being branded (as it should) just like `toUpperCase` with `hashed_` prepended would stop working... except `toLowerCase()` would completely pass your template literal check while messing with the uppercase characters in the token (thus it should no longer be a token, i.e. your program is now wrong).

Additionally branded types can have multiple brands[0] that will work as you expect.

So a user id from your DB can be a `UserId`, a `ModeratorId`, an `AdminId` and a plain string (when actually sending it to a raw DB method) as needed.

Try doing this (playground in [1]) with template literals:

  type UserId = Tagged<string, 'UserId'>
  
  type ModeratorId = Tagged<UserId, 'ModeratorId'>                     // notice we composed with UserId here
  
  type AdminId = Tagged<UserId, 'AdminId'>                             // and here
  
  const banUser = (banned: UserId, banner: AdminId) => {
    console.log(`${banner} just banned ${banned.toUpperCase()}`)
  }

  const notifyUser = (banned: UserId, notifier: ModeratorId) => {
    console.log(`${notifier} just notified ${banned.toUpperCase()}`)   // notice toUpperCase here
  }

  const banUserAndNotify = (banned: UserId, banner: ModeratorId & AdminId) => {
    banUser(banned, banner)
    notifyUser(banned, banner)
  }

  const getUserId = () =>
    `${Math.random().toString(16)}` as UserId

  const getModeratorId = () =>
    // moderators are also users!
    // but we didn't need to tell it explicitly here with `as UserId & ModeratorId` (we could have though)
    `${Math.random().toString(16)}` as ModeratorId

  const getAdminId = () =>
    // just like admins are also users
    `${Math.random().toString(16)}` as AdminId
  
  const getModeratorAndAdminId = () =>
    // this is user is BOTH moderator AND admin (and a regular user, of course)
    // note here we did use the `&` type intersection
    `${Math.random().toString(16)}` as ModeratorId & AdminId
  
  banUser(getUserId(), getAdminId())
  banUserAndNotify(getUserId(), getAdminId())             // this fails
  banUserAndNotify(getUserId(), getModeratorId())         // this fails too
  banUserAndNotify(getUserId(), getModeratorAndAdminId()) // but this works
  banUser(getAdminId(), getAdminId())                     // you can even ban admins, because they're also users

  console.log(getAdminId().toUpperCase())                 // this also works
  getAdminId().toUpperCase() satisfies string             // because of this

  banUser(getUserId(), getAdminId().toUpperCase())        // but this fails (as it should)
  getAdminId().toUpperCase() satisfies AdminId            // because this also fails
You can also do stuff like:

  const superBan = <T extends UserId>(banned: Exclude<T, AdminId>, banner: AdminId) => {
    console.log(`${banner} just super-banned ${banned.toUpperCase()}`)
  }

  superBan(getUserId(), getAdminId())                     // this works
  superBan(getModeratorId(), getAdminId())                // this works too
  superBan(getAdminId(), getAdminId())                    // you cannot super-ban admins, even though they're also users!
[0] https://github.com/sindresorhus/type-fest/blob/main/source/o...

[1] https://www.typescriptlang.org/play/?#code/CYUwxgNghgTiAEYD2...

hombre_fatal · 2 years ago
Finally someone writing practical examples instead of Animal / Dog / Cat.
arctek · 2 years ago
Thanks for the examples, I'm working on a TypeScript code base at the moment and this is fantastic way of adding compile-time typing across many of the basic types I'm using!
Animats · 2 years ago
Pascal worked that way all the time, and it was hated. You could have "inch" and "meter" version of integer, and they were not interchangeable. This was sometimes called "strong typing"

It's interesting that in Rust, "type" does not work that way. I kind of expected that it would. But no, "type" in Rust is just an alternate name, like "typedef" in C.

kevincox · 2 years ago
Both approaches are useful at different times. For example you wouldn't want to accidentally multiple a meter by a centimeter but you may want to provide std::io::Result<T> which is equivalent to Result<T, std::io::Error> but just a bit nicer to type.

For example in Rust you can do:

    type Foo = Bar;
Which is just an alias, interchangeable with Bar.

Or you can do:

    struct Foo(Bar);
Which is a completely new type that just so happens to contain a Bar.

earleybird · 2 years ago
NASA might have some thoughts on mixing inch and meter types :-)

https://en.wikipedia.org/wiki/Mars_Climate_Orbiter

exceptione · 2 years ago
It is a form of strong typing because integer could be the length of your toe nail, a temperature or the seconds since the unix epoch.

Sometimes you really want to make sure someone is not going to introduce billion dollar bugs, by making the type different from the underlying representation. In Haskell that would be sth like

     newtype Temperature = Int

At other times, you just want to document in stead of forcing semantics. A contrived example:

    type AgeMin = Int
    type AgeMax = Int

    isAdmissible :: AgeMin -> AgeMax -> Bool   
    isAdmissible :: Int -> Int -> Bool          // less clear

mc10 · 2 years ago
The AgeMin/AgeMax example seems more of a deficiency due to a lack of named function parameters; it would be equally clear if it had a type signature (using OCaml as an example) of

    val is_admissible : min_age:int -> max_age:int -> bool

stiiv · 2 years 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.

anamexis · 2 years ago
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;
      }
    };

stiiv · 2 years ago
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."

mattstir · 2 years ago
> 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.

stiiv · 2 years ago
Big thank you for clarifying -- I missed that. This approach is far less unsavory that some other attempts that I've seen.
sleazy_b · 2 years ago
I believe type-fest supports this, previously as “Opaque” types: https://github.com/sindresorhus/type-fest/blob/main/source/o...
jakubmazanec · 2 years ago
True, although "Opaque" was deprecated and replaced with similar type "Tagged" (that supports multiple tags and metadata).
mpawelski · 2 years ago
I like the brevity of this blog post, but it's work noting that this mostly feels like a workarounds for Typescript not supporting any form of nominal typing or "opaque type" like in Flow.
freeney · 2 years ago
Flow has actual support for this with opaque types. You just use the opaque keyword in front of a type alias ˋopaque type Hash = string` and then that type can only be constructed in the same file where it is defined. Typescript could introduce a similar feature
chromakode · 2 years ago
Alternatively for the case of id strings with known prefixes, a unique feature of TypeScript is you can use template string literal types:

https://www.kravchyk.com/adding-type-safety-to-object-ids-ty...

lolinder · 2 years ago
Note that the prefix was never intended to be looked at as the real problem. That's not a hash function, that's an example hash function because TFA couldn't be bothered to implement a proper one. They're not actually trying to solve the prefix problem.
kaoD · 2 years ago
This is why I always use `Math.random().toString(16)` for my examples :D People often get lost on the details, but they see `Math.random()` and they instantly get it's... well, just a random thing.
comagoosie · 2 years ago
Isn't there a risk with this approach that you may receive input with a repeated prefix when there's a variable of type `string` and the prefix is prepended to satisfy the type checker without checking if the prefix already exists?