TypeScript: Branded Types
prosopo.io
prosopo.io
You really don't need the symbol - you can use an obscure name for the branding field. I think it helps the type self-document in errors and hover-overs if you use a descriptive name.
I use branding enough that I have a little helper for it:
/**
* Brands a type by intersecting it with a type with a brand property based on
* the provided brand string.
*/
export type Brand<T, Brand extends string> = T & {
readonly [B in Brand as `__${B}_brand`]: never;
};
Use it like: type ObjectId = Brand<string, 'ObjectId'>;
And the hover-over type is: type ObjectId = string & {
readonly __ObjectId_brand: never;
} const accountId: Brand<string, "Account"> = "123654"
Has the error Type 'string' is not assignable to type '{ readonly __Account_brand: never; }'You have to make a function to apply the brand via a cast, the article explains this as well.
function makeObjectId(id: string): ObjectId {
return id as ObjectId;
} const accountId = "125314" as AccountId
It makes sense that the technique uses casting.But what if I instead wrote:
const accountId = AccountId ("125314" );
There would be the needed checks and balances in the function
AccountId(). Wouldn't that do pretty much the same thing? const accountId = "125314" as AccountId;
or const accountId = new AccountId("125314");
or const accountId = accountIdFromString("125314");
is all "explicit" casting in my understanding, one way or the other, and the first ... as ... being on the lowest level.I'd rather go for something like "do the casting close to the source, and in general use 'parse, don’t validate'[0] when getting input from the outer world".
[0]: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-va...
const accountId = AccountId (“43525”)
being less explicitIf you do add valuation, make it an honest asValidatedAccountId("123"), not an accountIdFromString("123") that may or may not do more than casting.
(PS, very much off topic: speaking of hidey-holes, are any of the AOP monstrosities still operational?)
const myCompanyId = something_complicated as CompanyId;
where something_complicated actually is a UserId, not a bare string.It is way too easy to accidentally destroy type safety with `as`, there are absolutely 0 safeguards.
I fear every single instance of `as` in any Typescript source I see.
Another way you can implement the same: through a class constructor like new UserId('some string') which can also throw or let you handle operations on the class itself while allowing you to get the value.
I don't love that example though, because the brand field's value of a string literal will make it seem like the object actually has a property named `__brand` when it doesn't. `never` is the best brand field type as far as I can tell.
If you assert that your id's actually do have a never-typed field, and you let your program access it, you're basically letting users enter an impossible state freely and easily.
Ideally you could brand with a private field, but we would probably need `typeof class` for that (assuming `typeof class` allows private members. I'm not sure).
But from the direction of usage...because you've used casting to (as far as TypeScript is concerned) construct the value, once it's floating around you're in an impossible state - and no, having a branded thing should not be an impossible state. Because of that you can freely violate the principles of the type system's logic - ex falso quodlibet.
A never value is effectively an any value, and now you have one on hand at all times.
https://www.typescriptlang.org/play?#code/FAMwrgdgxgLglgewgA...
If by some mistake your code gets a reference to a field with a `never` type, you’ll never get a type error passing or assigning that reference downstream. That’s relatively trivial to address if you can spot the (edit: runtime) errors it causes directly, but can wreak terrible havoc if it finds a path through several layers of function calls or whatever other indirection before the runtime consequences surface.
No it isn't. If your ids do have a field, then marking them as never having a field is unwise. Never means that code that reads that field is never wrong (because you can never reach the point of reading it), whereas you want the opposite, code that reads that field is always wrong.
GP just focused on the never-part of your question.
I wouldn’t call myself an expert in TypeScript’s type system (although I use it daily), so I’m not sure about that one.
I do know I would have omitted readonly if I did this myself, but perhaps by mistake.
Is your problem the line wraps in the parent’s comment?
export type Brand<T, Brand extends string> = T & {
readonly [B in Brand as `__${B}_brand`]: never;
};
I count 14 different pieces of punctuation. you might as well use Perl at that point. export type Brand<T, Brand extends string> = T & {
readonly [B in Brand as `__${B}_brand`]: never;
};
It exports a freshly defined type named Brand, which is a generic type with two parameters, one named T (that can be any type), and other named Brand (probably should be named differently to avoid confusion with the name of the whole exported type). Brand parameter must be a type that's assignable to string. For example it can be a type that has only one specific string like "Account" allowed as a value. It might be a bit weird but in places where type is expected "Account" is not treated as string literal but as a type assignable to string with only one allowed value, "Account".This whole exported type is defined as an intersection type of parameter T and object type that has readonly properties for every type that is assignable to the type passed in as parameter named Brand when this generic type is instantiated. For example if "Account" was passed then B can only be "Account" here, so this object type will only contain a single property. Key of this property will be a string that looks like __Account_brand (if the Brand was "Account") and the type of this property will be never, so nothing is allowed to be assigned to this property.
The result of instantiation of this whole exported generic type will be a type which values can be assigned to variables of type T, but attempt to assign value of type T (or type branded with some other string) to variable of this branded type will result in an error because of missing or mismatched brand property.
This definition might allow for interesting complex branding like:
let id:Brand<string, "Employee" | "Manager">;
that can be assigned to variables and parameters of types Brand<string, "Employee"> or Brand<string, "Manager"> or just string.PS.
Intersection type constructed from multiple types in TS using & symbol is a type which values are assignable to variables of each of the constituent types.
For example value of a type Logger & Printer can be assigned to variables and parameters of type Logger and of type Printer as well.
If for example the language was missing builtin hashmap type, its implementation would be nasty as well.
I'm not a huge fan of typing systems, the thing I like the most in TypeScript is that you can use types as little as you want and add more only when you want it. But I don't like languages like go that intentionally lack features.
people will go as far down the rabbit hole as you let them, which is what the Go developers understand and are trying to account for:
from typing import NewTypeI really like the pattern of value objects from Domain Driven Design. Create a class that stores the value, for example email address.
In the class constructor, take a string and validate it. Then anywhere that you need a valid email address, have it accept an instance of the Email class.
As far as I understand classes are the only real way to get nominal typing in TypeScript.
It is also possible to then infer a type from a class so you can use both the class where you want to discriminate types and the type where you really only care about the shape.
The absolutism towards OOP/FP -- instead of embracing the right use cases for each -- always ruffles me in the wrong way. C#, for example, does a great job of blending both OOP and FP (borrowing heavily from F# over the years). JS and by extension TS has the same flexibility to use the right paradigm for the right use cases, but it seems that everyone wants to be on one end of the spectrum or the other instead of accepting that JS is an amalgamation.
Evan You had a great quote on this where he correctly calls out much of the complexity and performance issues with React as being rooted in pushing against the language rather than embracing it.
[0] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
[1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
… also doesn’t need to proscribe use or non-use of classes. People were doing OOP in JS long before classes were added to the language. I routinely use classes in a FP style because they’re excellent for modeling value types with a stable structure.
What's particularly better these days is that you get nominal typing and brand checks with standard private fields:
class Foo {
#brand;
static isFoo(o): o is Foo {
return #brand in o;
}
}Branding primitives is useful because you don’t otherwise have a name that you can control (e.g. feet versus meters as was suggested elsewhere in this thread, both of which would have type “number”).
The article starts with the example of two different object types with field “x: number”. In that situation you should be branding the field, not the object! If the field has the same name and the same branded type, structural typing is correct for that object shape.
Although classes and instances are ultimately structural, right?
let foo = new Foo();
assert(foo.constructor == Foo);How are classes going to help? As far as I understand TS is structural period. Ex this is valid (!):
class Email {
constructor(public email: String) {}
}
let x: Email = new Email('foo@foo.com')
let y = { email: '73'}
x = y;For example, these two classes are distinct to TypeScript:
class Name {
constructor(private value: string) {}
getValue() {
return this.value;
}
}
class Email {
constructor(private value: string) {}
getValue() {
return this.value;
}
}
const email: Email = new Name(“Tim”); // Error: (paraphrasing) conflicting private declaration of “value”.I am surprised your code doesn't give an error, x and y are definitely not the same thing because `x instanceof Email` would be false at the end of that code. But like you said, to TS x and y are indeed the same type because they have the same structure. In practice both can be used interchangeably in the code (even if Email extended another class) with the sole exception of the `instanceof` keyword.
type Email = Branded<string, 'email'>
function Email(maybeEmail: string): Email {
assertIsEmail(maybeEmail);
return maybeEmail;
}
function assertIsEmail(value: string): asserts value is Email {
if(!isEmail(value)) throw TypeError("Not an email");
return value;
}
function isEmail(value: string): value is Email {
return value.contains("@");
}
Versus: class Email {
#email: string;
public constructor(maybeEmail: string) {
if(!isEmail(maybeEmail)) throw TypeError("Not an email");
this.#email = maybeEmail;
}
valueOf() {
return this.#email;
}
}- currencies
You may have some sort of integer representing the number of cents of some currency and you want to avoid doing operations between the wrong currencies (such as adding euros and pesos).
You can create branded types and functions that work on those Euro branded numbers and then decide how to do math on it. Or numbers representing metals and such.
It's useful in other scenarios such as a, idk, strings, you could theoretically brand strings as idk ASCII or UTF-8 or the content of an http body to avoid mixing those up when encoding but I want to emphasize that often many of those hacks are easier to be handled with stuff like dictionaries or tagged unions.
An example of what can be achieved with similar approaches (beware it's oriented for people that are at least amateur practitioners of functional programming) is Giulio Canti's (an Italian mathematician and previously author of t-comb, fp-ts, io-ts, optic-ts, and now effect and effect/schema), the money-ts library:
Branding is easily circumvented, so it's best as a developer hint that helps document APIs and alerts us to common mistakes as an incremental improvement over primitives.
For money and similar I would use objects and a custom math library.
And I'm struggling to think of where in a codebase I might want to hardcode Pesos or Euros.
My feeling is that while you can do this, you’re swimming upstream as the language is working against you. Reaching for such a solution should probably be avoided as much as possible.
Right, but this is one of those situations where a reasonable person could conclude "The language is not working to help me solve the problem I'm trying to solve." Sometimes you don't want JavaScript sloppiness and you also don't want TypeScript structural-type "sloppiness," desiring instead nominal types.
This is a clever way to use the existing infrastructure to get nominal types with no additional runtime overhead.
type Velocity = number & { BRAND: "Velocity" }
type Distance = number & { BRAND: "Distance" }
var x = 100 as DistanceOr you have a client library that you use to interact with your API, and the client library changes, and you don't even notice.
Or you change the return type of a function in a library, and you don't even know who all the callers are, but it sure would be nice if they all get a build error when they update to the latest version of your library.
Lots and lots and lots of ways for this to happen in a medium+ sized project that's been around for more than a few months. It's just another way to leverage the power of types to have the compiler help you write correct code. Most of the time most people don't mess it up, but it sure feels good to know that it's literally impossible to mess up.
function foo(customerId, itemId, orderId) {
if(!customer[customerId]) throw new Error("Customer with id=" + customerId + " does not exist");
if(!item[itemId]) throw new Error("Item with id=" + itemId + " does not exist");
if(!order[orderId]) throw new Error("Order with id=" + orderId+ " does not exist");
}
Just an example of how you can detect errors early with defensive programming.It is equivalent to newtype in haskell.
Another example somebody mentioned here is a logged in user. A function can take a user and we need to always check that the user is logged. Or we could simply create a LoggedUser type and the compiler will complain if we forget.
For Rust I wrote newtype-uuid to provide newtype wrappers over UUIDs, which has already found a couple of bugs at Oxide.
A user is free to leave and rejoin a stream, and we want to retain old data. So each user_stream has columns id, user_id, stream_id (+ others)
Issues occur when people write code like the following:
streamsService.search({ withIds: userStreams.map((stream) => stream.id), });
The issue is easily noticed if you name the “stream” parameter “userStream” instead, but this particular footgun came up _all_ the time in code review; and it also occurred with other junction tables as well. Branded types on the various id fields completely solve this mistake at design time.
The web is stuck with JavaScript, and Typescript is a tool to help us write maintainable JavaScript.
Would be a fun thing to do for sure, but never as fast as the APIs built into the browser runtime.
To do this kind of thing viable there are two ways I can think of:
1) proper ahead-of-time compiler for your language targeting WASM directly. But that means pretty much rebuilding the whole stack for the language as that is a completely different approach.
2) Do something like Android Runtime (ART) does which translates Java bytecode to native machine instructions whenever you install an android app. But in this case translate the bytecode to WASM and do it before distribution. This requires a new compiler-backend which is still quite complex.
Both of these mean you don't have a language runtime at all. There is a reason most WASM stuff you see is in written C/C++/Rust and it is not just the lack of GC.
ListInterface list = new ConcreteList();
let runtimeType = GetTypeOf(list); // will be `ConcreteList`, not `ListInterface`
This is an imaginary java-like language, but I'm not aware of a statically typed language that gives you the static type resolutions at run-time, outside of cases like implicit generic resolutions and things like that.It’s the thing that made it an easy sell after everyone got turned off by the long-term experience of Coffeescript and such. It’s the reason various “better” typed languages that run on top of JS have flopped except with enthusiasts.
TS could have just as easily chosen nominal typing + a simple way to do typedefs and had everything else work like it does now. But structural typing gives you a lot of other useful features.
Funnily enough, Haskell[0] doesn't... unless you ask it to by explicitly asking for it via Typable[1].
[0] ... which is renowned/infamous for its extremely static+strong typing discipline. It is nice that one can opt in via Typeable, but it's very rare to actually need it.
[1] https://hackage.haskell.org/package/base-4.19.1.0/docs/Data-...
Try Dart, maybe you like it. It's like TS but with it's own runtime.
Determining types by what things appear to do instead of what they state they do seems like a generally unnecessary compromise in typing safety that isn't really worth the minuscule amount of effort it can save.
type UserId stringOr perhaps I'm misunderstanding your comment. When you do `as OrgId` or `as UserId`, where do you envision those casts in ways that would require handling failures?
A lot of times branding is used to mark data received from a database, e.g.: this field is an `OrgId`, but I can do all kinds of things to that string which might make it not-an-`OrgId` at any point. Then, I'll try to reuse it the `OrgId`, and I'll get some weird error or blowup and I'll have no idea why. So the point is that (a) branding is a dubious feature because it can obfuscate soft-type-breakage (I call it "soft" because at the end of the day, we're just dealing with strings), and (b) it still doesn't preclude runtime error checking unless you're okay with blowups.
Isn't this all frontend client bundles that talk to their own private backend API? Those are controlled end-to-end. My company has one, yours probably does too!
The raw data from the API will not have any of your internal types applied to it yet, it'll be raw bytes or typed `string`. So I don't really see the connection between this and "I will still always be able to do `as OrgId` or `as UserId` so you need to do have some failure handling". Only your own trusted code can do "as OrgId", so... don't do that unless you have an OrgId. And once your own trusted code has made an OrgId, you don't need any runtime checking to see if it actually is an OrgId.
> A lot of times branding is used to mark data received from a database, e.g.: this field is an `OrgId`, but I can do all kinds of things to that string which might make it not-an-`OrgId` at any point.
What kind of things? Strings aren't mutable so you must be making a new string. But a new string will have a type like `string`, not `OrgId`, and then using it as an OrgId won't compile.
Right, and once I have a verified OrgId, I'll just keep using the `myOrgId` variable throughout my code, and I don't really need branding. Maybe I can do type aliasing to make the code easier to read (type OrgId = string), but hardline type verification via branding seems moot unless you can make strong runtime guarantees. I mean, don't get me wrong, I think it's a cute novelty, but it doesn't really do anything.
> But a new string will have a type like `string`, not `OrgId`, and then using it as an OrgId won't compile.
Exactly. Maybe I'm wrong, but in a real codebase, I bet branding would probably just confuse people. "Why can't I change the last number of an OrgId?"—well, you see, once you do that, you lose the brand so now you need to manually do `as OrgId`.
In fact, a common pattern is to pass fully-qualified objects, e.g. `dimensions = {width: number, height: number}`, which makes mixing up variables even less likely since you have to explicitly specify them.
I literally just showed you how they don't. And you even go on to describe a pattern that makes the problem "even less likely" in the next sentence..
>Branding seems like a solution looking for a problem.
You do you.
I would rather put that information in the type system than in the variable name.
It prevents passing the wrong variable, is that not useful?
> Exactly.
I don't see how what I said agrees with what you said. Making it not an OrgId prevents the weird blowups.
A compilation error because you used the wrong type is not a blowup, it's preventing random blowups.
And you shouldn't be shuffling digits using string code, that's the point. If you have a way to transmute OrgIds, it should be a function that returns an OrgId.
I'd question whether people even need to know OrgId is a string.
I mean, with numbers it's even more confusing (this might be a TS bug?):
type SpecialNumber = number & { __brand: "SpecialNumber" };
let n: SpecialNumber = 42 as SpecialNumber;
n++; // works (but should break)
n+=1; // breaks
n = n + 1; // breaks
I understand the purpose behind it, I just think it's needlessly confusing and obtuse, and would be curious to see any serious code base that uses branding.e.g. If I am parsing a string to a number via Number.parseInt, I don’t need a “: number” annotation because I can just call the variable “myNumber” and use that.
Branding a string is in many ways an extension on the idea of “branding” my “myNumber” variable as “: number” rather than leaving it as “: any”. Even if the TS type system is easy to bail out of, I still want the type annotations in the first place because they are useful regardless. I like reducing the number of things I need to think about and shoving responsibility off to my tools.
It’s not about security, it’s about safety: make it harder to do the wrong thing, and make it easier to do the right thing than the wrong one.
You can do this kind of thing in "proper" statically typed languages as well like "(Whatever)((Object)x)
The main problem in TS vs Java for this specific case is that if x is NOT Whatever then you get an error some point later in the code. In Java you would get the error immediately at the casting.
It makes debugging a bit trickier but I don't really run into these kind of problems all that often in my TS code and when I do they are usually easy to track down.
Especially on the frontend, where your `throw new Error("Bad input type")` might brick the entire app if uncaught. I'd much rather hear an earful from TypeScript before a bundle is ever produced.
This is both! The primary point of branded types is to allow you to use the type system to ensure that a particular runtime check has taken place.
I see. This is kind of cool, though the branding can still be broken via down-the-stream mutations. Would be nice to enforce re-branding every time a variable is changed, but that seems like a lot of overhead.
Only for mutable values! A branded string should be as immutable as they come, right?
I don't think it has any relation to runtime type checking at all. It's refinement types, [4] or newtypes[5] depending on the details and how you shape it.
[1] https://github.com/microsoft/TypeScript/blob/main/src/compil... [2] https://github.com/microsoft/TypeScript/issues/4895 [3] https://github.com/microsoft/TypeScript/pull/33038 [4] https://en.wikipedia.org/wiki/Refinement_type [5] https://wiki.haskell.org/Newtype
The article reads more like an endorsement of languages that do structurally-aware nominal typing (that is, languages that are nominally typed but that have awareness of structural equivalence so that zero-cost conversions and intelligible compile-time errors for invalid conversions are first class) than a persuasive case for the symbol-smuggling trick described.
C++'s templating lets express some very powerful type constraints... But good luck reading the compiler errors generated by someone else's very powerful type constraints that you haven't fully grokked to an implementation level.
Works especially well if you're using any kind of hexagonal architecture, make your functional core only accept validated/escaped/parsed/whatever types, and then the imperative shell must send any incoming data through whatever transformation/validation/etc before it can interact with the core.
However I just inlined the "tag". Also like yours but unlike OP there is no runtime tag. Which can be a pro (no extra code) or a con (can't do runtime checks).
I'm curious to see what the JS code looks like for casts and type checks in that case.
More like: magical disappearing type system is not nominal: hacky workarounds ensue.
I guess working in the pure logic and formalism of types is just addictive.
I love it.
Although if you do a bad job it might add a lot...
type ObjectId = string & {
readonly __tag: unique
symbol }
This way, we don't need to use `never`, but we still prevent the creation of a structurally equivalent type by mistake.I think Haskell avoid this by actually requiring you to write sound conversion functions between phantom types (it helps that phantom types don't involve any visible state at runtime).
Also nominal types for classes.
And correct variance.
And adhering to liskov substitution principles.
And exact object types.
And spread on types matching runtime behavior.
And proper no transpilation mode with full access to the language.
And has 10x less LoC than ts.
ps. before somebody says "flow is dead" have a look at flow contributions [0] vs typescript contributions [1]
[0] https://github.com/facebook/flow/graphs/contributors
[1] https://github.com/microsoft/TypeScript/graphs/contributors
Flow is more principled.
It doesn't change the fact that ie. adding private member is breaking change in your library which is kind of funny (until it's not funny of course).
Also stuff like:
class Foo { private foo = 1 }
class Bar {}
const a: Bar = new Foo
...typechecks so that's it for nominality.It's all ifs all the way down.
And yeah, I’m not a fan of that class instance assignability case. Not to make excuses for it, but I have other reasons I generally prefer to expose interfaces (distinct from classes, even if they’re identical in shape and even have the same internal purpose) at most API boundaries; avoiding that particular footgun just turns out to be a nice happy side effect of the preference.
what does this even mean?
> And has 10x less LoC than ts
Prolly b/c Flow isn't able to express the advanced types (albeit with 10x LoC) in the first place.
What advanced types do you have in mind?
ps. the way you're using "albeit" sounds like you think flow has 10x larger codebase, it has 10x smaller codebase
It's the only one from the SOLID list which can be typechecked - others are design principles.
"Flow adhering to it" means that violating code will be flagged by type system.
It matters because unlike other principles, violations can cause runtime errors.
you think?
> Flow adhering to it" means that violating code will be flagged by type system
Shocking that type system can flag things
I expected thousands.
Handling difficult cases leads to more type errors.
If type system is lax, it won't flag them but they can fail at runtime.
React has very little of $FlowFixMe annotations for its codebase.
In typescript projects on the other hand it's normal to see unsafety as normal code (casting, non null assertions, implicit and explicit anys etc).
Additionally, I've read a lot of typescript code, and the react codebase. My experience is different than yours. I see more type workarounds in react.
FWIW "$FlowFixMe" occurs 822 times in commit hash cf5ab8b8b2c92523aba0b982fb403add053a9110 out of 498900 lines of *.js source. That includes blanks and comments. I don't have any good stats on the occurrence of type assertions in typescript.
I think you're double counting it (original files + emitted files)?
git clone git@github.com:facebook/flow.git
grep -roh '$FlowFixMe' flow | wc -l
423
Yes, it's not easy to grab good stats, my experience is that in ts projects you have much more of explicit type annotations vs flow which has better inference, a lot of type casting (unsafe), implicit and explicit anys (unsafe), null assertions (unsafe), unsafe OO etc.The code in flow has very interesting feel. Missing explicit type annotations are noticeable, feels like js. Optional names in type signature means functions have very Haskell'ish feel:
const foo /*: number => string */ =
x =>
x.toString()
I use flow in comments so I don't have transpilation. Access to full language means a lot here.Typescript has some nice things that flow doesn't (ie. template literal types) - but recent activity in flow is very interesting. They've put a lot of effort into making flow understand typescript constructs. Many people don't realize how close they now became.
Even some constructs are implemented first in flow (type guards, in ts scheduled for not yet released v5.5 I believe; NoInfer landed first in flow), then in typescript.
It seems they have opportunity to make flow compatible with typescript enough that you could consume ts typedefs from flow - and when it happens it's definitely going to help opening doors wider for adoption.
[0] https://github.com/search?q=repo%3Afacebook%2Fflow+%24FlowFi...
Fortunately it wasn’t and now I get to not-hate working in JavaScript.
They also fucked up typings in terms of community management.
Those two alone probably put them into downward spiral.
But the language is being developed with activity stronger than ever.
And it is well designed and pleasure to code in.
Sometimes I wonder what kind of programs are written using all these complicated TS types. Anecdotally, we use very simple, basic types in our codebase. The need for tricks like this (and other complicated types) is simply not there. Are we doing something wrong?
I could definitely see using something like this to force some constraints on my favorite bugbear for my problem domain: values that should have units. It matters a lot if "time" is nanoseconds, microseconds, or seconds, but most time-related functions just take in "number" like that's okay and won't cause thousand- or million-fold magnitude errors.
This is one way to provide some language safeguarding against treating 5 nanoseconds as the same as 5 microseconds.
Data validation, typed database access, or functional programming libraries are good examples. Particularly the modern, leading libraries of such areas, if you look into their code you'll generally see very intricate typing. For FP libraries it's particularly tough. I like to use Remeda which emphasizes being very type-safe, but that means it's inherently more limited in what functions it can offer compare to other libraries which choose to compromise their type-safety. These kinds of techniques mean that libraries can offer greater functionality while remaining type-safe.
If you argue that this is not a common problem in practice, then I tend to agree. I haven't seen code with mixed up arguments very much, but when it does happen it can have bad consequences. IMO there is not a big cost to pay to have this extra safety. In TypeScript it's ugly, but in other languages that natively support this typing like Scala, Rust, Swift and Haskell, it works nicely.
I would also agree that it's harder to confuse complex types as any single instance of a type is unlikely to overlap once you have a few fields.
Unless you accidentally create the wrong branded type? Which is as likely as disordered arguments.
As you stated, tests should cover this case trivially, I don't see the value in added type complexity.
It just depends on how constrained by types you want your code to be and how much time and effort you're willing to spend maintaining and writing code that fits within those constraints.
Sometimes complicated types are introduced because you want to maintain editor features such as find by reference and the ability to refactor later. When it works, removing or adding new features feels fast, easy and safe.
In the articles case you could differentiate between an email string type and a user id string type. Maybe sometime in the future you want to change the id to an integer instead, so now that it's already distinguished you could find all those places where that's applied.
That's at least a selling point, in practice I've used this a few times but it doesn't come up that often. Sometimes the blast radius of a type isn't big so it's not worth doing.