Live data from Hacker News

Branded types for TypeScript

carlos-menezes.com

51–60 of 152 posts

Re: Branded types for TypeScript

#51
post #3

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 ‘st…

You can still do this with classes in typescript: class Hash extends String {} https://www.typescriptlang.org/play/?#code/MYGwhgzhAEASkAtoF...

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

Re: Branded types for TypeScript

#52
post #51

Earlier quoted context omitted.

You can still do this with classes in typescript: class Hash extends String {} https://www.typescriptlang.org/play/?#code/MYGwhgzhAEASkAtoF...

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(en…

Or, a version that's more inline with the post you're replying to.

Just add an Email class that also extends String and you can see that you can pass an Email to the compareHash function without it complaining.

  class Hash extends String {}
  class Email extends String {}

  // Ideally, we only want to pass hashes to this function
  const compareHash = (hash: Hash, input: string): boolean => {
    return true;
  };
  
  const generateEmail = (input: string): Email => {
    return new Email(input);
  }
  
  // Example usage
  const userInput = "secretData";
  const email = generateEmail(userInput);
  
  // Whoops, we passed an email as a hash and TS doesn't complain
  const matches = compareHash(email, userInput);
https://www.typescriptlang.org/play/?#code/MYGwhgzhAEASkAtoF...

Re: Branded types for TypeScript

#54
post #3

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 ‘st…

You can still do this with classes in typescript: class Hash extends String {} https://www.typescriptlang.org/play/?#code/MYGwhgzhAEASkAtoF...

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

Re: Branded types for TypeScript

#55

Earlier quoted context omitted.

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.

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 Stri…

As mentioned elsewhere, what this is actually doing is showing that string and String are not structurally equivalent in TS.

If you add another class Email that extends String, you can pass it as a Hash without any problems. And you can get rid of the Hash stuff altogether and do something like

  compareHash(userInput, new String(userInput)); 
and that fails just as well as the Hash example.

Using extends like this doesn't actually fix the problem for real.

Re: Branded types for TypeScript

#56
post #37

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…

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

Re: Branded types for TypeScript

#57
post #45

Earlier quoted context omitted.

There is nothing to fix in my example, I was just highlighting the difference between nominal and structural typing. Adding a private field to the class is a form of branding (just like adding a Symbol key to a primitive).

The point is that Typescript does have nominal typing. It's used if a class is declared with any kind of private member, and for `unique symbol`s. So both in the case I showed, and the case shown in the article, we are using true nominal types. In fairness, we're also using branded types, which I think is confusing the matter here*. But they are specifically branded nominal types. We can also create structurally-type…

> which are true nominal typing.

One part that was not clear to me without testing, and since I do not use typescript regularly, was that you only get nominal typing between the classes that share the private member and if you start going out side that set you lose nominal typing. So you do not get a nominal type, but you can get a subset of types that when interacting with each other act as if they were nominal types.

So class Cat that uses `private __force_nominal!: void` can still be used as class Dog if Dog does not have `private __force_nominal!: void`.

Example[1]:

    class Dog {
        breed: string
        constructor(breed: string) {
            this.breed = breed
        }
    }

    function printDog(dog: Dog) {
        console.log("Dog: " + dog.breed)
    }

    class Cat {
        private __force_nominal!: string
        breed: string
        constructor(breed: string) {
            this.breed = breed
        }
    }

    const shasta = new Cat("Maine Coon")
    printDog(shasta)
edit - the above type checks in typescript 5.4.5

[1] modified example from https://asana.com/inside-asana/typescript-quirks

Re: Branded types for TypeScript

#58

Earlier quoted context omitted.

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

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…

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

That's not quite true. Branding doesn't exist at run time, where as nominal typing usually does at some level. Classes exist at runtime, but most typescript types don't, so unless there's something specific about the shape of the data that you can check with a type guard, it's impossible to narrow the type.

Re: Branded types for TypeScript

#59
post #56

Earlier quoted context omitted.

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

Isn’t it the same in TypeScript? You don’t need an explicit type field.

Re: Branded types for TypeScript

#60
post #21

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]. A…

> 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
  
  type ModeratorId = Tagged                     // notice we composed with UserId here
  
  type AdminId = Tagged                             // 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 = (banned: Exclude, 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...

Post reply on HN