15 ms·
TypeScript: Branded Types
- pprotas 2y agoAmazing, we have invented nominal typing in a structural typing system. 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?
- skybrian 2y agoI use branded types for database id’s to avoid mixing them up. It seems like better documentation and might catch some errors, but mixing up database id’s seems like an uncommon error, so hard to say.
- odyssey7 2y agoNope, if you have lower expectations for your type system and are using TS then you are properly where you want to be.
- shadowgovt 2y agoProbably not. 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.
- chowells 2y agoYou've never had values that are only valid within a specific context and wanted to prevent using them incorrectly?
- IshKebab 2y agoYeah structural typing assumes that all fields with the same name & type mean the same thing, but that clearly isn't always the case.
- brigadier132 2y agoYou don't use them but your favorite libraries do.
- maeil 2y agoThis is very accurate, in particular when the output type of something depends on the input type. 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.
- JJMalina 2y agoI think the example could be better in this article. Let's say you have a function that takes a database id, an email address, and a name. They're all strings. If you pass arguments to this function and you mess up the order for some reason then the compiler has no idea. Hopefully your unit tests catch this but it won't be as obvious. Branded types solve this problem because you can't pass a name string as an argument that is expected to be an email address. 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.
- LadyCailin 2y agoThat’s just plain encapsulation, if I understand you correctly. Branding, on the other hand, prevents complex types from being confused.
- mason55 2y agoNot really, not for TS at least. If you just want to take a string and call it an email address and have your function only accept email addresses (the simplest use case) then you need to use branding. That's not encapsulation.
- antifa 2y agoOr double your GC burden by using a wrapper class.
- ebolyen 2y agoBranding works on primitive types as well, which is I think the most interesting use case. 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.
- contextnavidad 2y ago
- amatecha 2y agoYeah no, IMO this seems totally extraneous and a layer of complexity not worth introducing to any project. I've never encountered a case where I care that I need to differentiate between two types of exactly the same structure, and my gut feeling (without actually prototyping out an example) is that if this actually makes a difference in your code, you should probably be making the differentiation further up in the flow of information...
- eyelidlessness 2y agoNot necessarily wrong. You’re probably doing the same work at runtime. That might be either just as good (a subjective preference) or more correct (for certain kinds of dynamism) depending on context. In some cases, there are clear static invariants that could be guaranteed at compile time, and some folks will be more inclined to squeeze as much out of that as possible. In my experience, the benefits of that vary wildly from “total waste of time” to “solves a recurring and expensive problem for good”, with a lot in between.
- CapsAdmin 2y ago> The need for tricks like this (and other complicated types) is simply not there. 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.
- jonathanlydall 2y agoI can see this being useful and it seems about as neat a solution as you can currently get in TypeScript as it stands today, but it’s still cumbersome. 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.
- shadowgovt 2y ago> as the language is working against you 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.
- kristiandupont 2y agoI don't see why. I greatly prefer typescript's structural typing for almost everything. But id's in data models are an exception, so I use branding for those. It works perfectly, the only overhead is in the write-once declaration and now I am protected from accidentally using an AccountId where a MemberId was expected, even though they are both just strings.
- cush 2y agoYes! I've wanted this in Typescript for ages! Is it possible to brand primitives though?
- kristiandupont 2y agoYes you can, it works just the same :-)
- cush 2y agoSorry, with the Symbol? How does it work?
- JJMalina 2y agoZod has branded types: https://zod.dev/?id=brand https://zod.dev/?id=brand it works really nicely with Zod schemas
- spankalee 2y agoTo me the real benefit of branded types is for branded primitives. That helps you prevent mixing up things that are represented by the same primitive type, like say relative and absolute paths, or different types of IDs. 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; }
- deleted 2y ago[deleted]
- cush 2y agoSorry, how do you apply it to branding primitives? The basic const accountId: Brand<string, "Account"> = "123654" Has the error Type 'string' is not assignable to type '{ readonly __Account_brand: never; }'
- beeboobaa3 2y agoThis error is, in fact, the point. It keeps you from accidentally assigning a normal string to a branded string 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; }
- cush 2y agoAh, yeah the error makes sense. I expected the error, just wanted to understand how Brand was meant to be actually assigned to a primitive. I'm not sure the function is necessary though. This does the same thing const accountId = "125314" as AccountId It makes sense that the technique uses casting.
- leecommamichael 2y agoThe Odin language has a ‘distinct’ type-qualifier which accomplishes this tersely. It’s a big part of the type system, and C’s type-system for that matter.
- techn00 2y agoVery useful for DDD, like having an Email type, or String100 (string of 100 characters)
- mason55 2y agoWorks very well too for any kind of validation or encoding. Anything that accepts input from the outside world can accept a string. And then everything else in the app can work with a "SafeString" and the only way to create a safe string is to send a string through a string escape function (or whatever makes sense for your app). 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.
- zbentley 2y agoIt's a clever trick, but the compiler errors leave a lot to be desired. If a TS library makes heavy use of nominal (branded/distinct) types in a domain where accidentally passing values of the wrong type is common, I can imagine a lot of library users being more confused, not less, by code that uses this approach. 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.
- shadowgovt 2y agoThis is one of those frustrations I encounter with C++. 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.
- eropple 2y agoGenerally speaking, I wouldn't use branded inputs for libraries. Branded types make a lot more sense to me when working in business-logic cases, to identify data at the edge of a bounded context and tracing through the system. A library is downstream of that and the code that requires the branded type should be controlling the inputs to the library.
- eyelidlessness 2y agoI’ve found TypeScript errors can be made a lot easier to understand with some forethought about how invalid types are defined in the first place. For instance, a many-branched conditional type may be much easier to understand (both as a library user and as a reviewer/maintainer of the library code itself) if each `never` (or whatever actual failure case) is instead a string literal describing the nature of the failure. And better still if it can be templated with an aspect of the input type to highlight what aspect of it triggered the error.
- jibbit 2y agoor a tagged type if you're old enough to have ever been exposed to another language
- mirekrusin 2y agoFlow is much better with opaque types. 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 https://github.com/facebook/flow/graphs/contributors [1] https://github.com/microsoft/TypeScript/graphs/contributors https://github.com/microsoft/TypeScript/graphs/contributors
- eyelidlessness 2y agoFWIW, classes in TypeScript become nominal types when they have a non-public member. But I definitely do feel real FOMO over Flow’s opaque types.
- mirekrusin 2y agoYes, ts is full of this kind kind of adhoc-ifs-like glued together, also exactness check only when it's literal object etc. Flow is more principled.
- eyelidlessness 2y agoI mean. I think this specific case is a lot more principled than you seem to think. It’s certainly well reasoned from a perspective of structural typing by default.
- mirekrusin 2y agoYes, they have their reasons. They always do, tradeoff etc, I know. 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.
- klysm 2y agoThe downside of structural typing
- dvt 2y agoWeird idea, as types in TS are structural by design. If this is something you need, it smells like "runtime checking" not amending the type system.
- williamdclt 2y agoDesign doesn’t cover all use cases. There’s nothing weird about wanting different types for “user id” and “organisation id” so that you don’t use the wrong argument by mistake
- dvt 2y agoWhile you're right that branding makes passing arguments more ergonomic, I will still always be able to do `as OrgId` or `as UserId` so you need to do have some failure handling anyway, unless you're okay with blowing up in the user's face.
- strongly-typed 2y agoThe way you're describing it sounds to me like you are adding failure handling to handle cases where the programmer is intentionally misusing the thing. I would argue that in this type of situation, the error handling is not necessary because it would hide the fact that the thing is being misused. Or 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?
- dvt 2y agoMy point is that the API consumer does not guarantee types (say it's REST or something), so the assumption that the string you send it will always be the right type (or call it "format") seems like a bad one. Unless you control API usage from end-to-end (which kind of defeats the point of an API), you need error checking (or at least exceptions to bubble up). 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.
- dimitrisnl 2y agoSelfish plug about the same topic https://dnlytras.com/blog/nominal-types https://dnlytras.com/blog/nominal-types
- kevincox 2y agoI also wrote something like this and came up with something similar to yours: https://kevincox.ca/2023/03/21/lying-to-typescript/#summary https://kevincox.ca/2023/03/21/lying-to-typescript/#summary 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).
- erik_seaberg 2y agoThis works because casts are allowed to quietly create values whose types are wrong. It would have been better if the cast added a runtime check, or at least we distinguish sound (checked) and unsound casts the way C++ does. 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).
- diarrhea 2y agoAs a TypeScript beginner, I was bitten by this. Typing in TypeScript feels quite bad for some reason; a lot of effort for effects which are still potentially wrong at runtime. I didn’t struggle so much in Python, Rust or C#, for example. Python is surprisingly… sound? in comparison. It can do nominal as well as structural typing.
- beders 2y agoAh, the magical disappearing type system - now being used for nominal typing. I'm curious to see what the JS code looks like for casts and type checks in that case.
- williamdclt 2y agoNothing special about casts and type checks. It’s just a simpleish workaround for when nominal typing is more useful
- klysm 2y ago> Ah, the magical disappearing type system - now being used for nominal typing. More like: magical disappearing type system is not nominal: hacky workarounds ensue.
- Slix 2y agoThis is also useful for having a type that needs to be verified in some way before being used or trusted. UnverifiedLoginCookie vs. VerifiedLoginCookie
- lang_agnostic 2y agoIsn't this just phantom types in other programming languages?
- valcron1000 2y agoMore like `newtype` with implicit `coerce`
- thepaulmcbride 2y agoAnytime I’ve come across the need to do this, I’ve found a class is a better and less complicated solution. I 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.
- CharlieDigital 2y agoClasses can also make use of the native `instanceof` JavaScript operator [0]. 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/Reference/Operators/instanceof https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
- thepaulmcbride 2y agoClasses are very underutilised in TypeScript. I recently introduced them to our codebase at my day job and got a fair bit of pushback because it wasn’t “JavaScripty” enough.
- tadfisher 2y agoThat's a weird objection, because Typescript classes are literally Javascript classes[1]. [1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
- bazoom42 2y agoI think Typescript need nominal type aliases for primitives.
- satvikpendem 2y agoReminds me of another sort of type-driven development, making invalid states unrepresentable: https://geeklaunch.io/blog/make-invalid-states-unrepresentable/ https://geeklaunch.io/blog/make-invalid-states-unrepresentab...
- herpdyderp 2y agoThe implementation for `RemoveBrand` is incorrect: it currently grabs all property types of `T`, it's not removing the property `[brand]` from `T`. It should be `Omit<T, typeof brand>`
- munk-a 2y agoIsn't this just the classic issue of inferred typing coming back to bite us in the way everyone originally predicted? Go runs into the same issue where wildly different types may be considered the same based purely on coincidental naming and matching against interfaces the original authors had no intent to match against. At the end of the day I think the easier system to work with is one in which all type compatibility needs to be explicitly declared - if your array is iterable defining that it implements iterable lets your compiler shout at you if iterable suddenly gets another abstract method that you didn't implement - and it makes sure that if you add a method `current` to a class it doesn't suddenly means that it properly supports iterable-ity. 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.
- skybrian 2y agoI haven’t run into interfaces coincidentally matching in Go. Have you? It might happen more easily for primitive types, though, and Go does have a way to declare new types: type UserId string
- klysm 2y agoAre these simple aliases or nominally distinct types?
- yencabulator 2y agoNominally distinct. https://go.dev/play/p/4am-a1i4_ox https://go.dev/play/p/4am-a1i4_ox
- Aeolun 2y agoI mean, they have to stay backwards compatible with Javascript. That kind of limits your options.
- munk-a 2y ago
- hankchinaski 2y agoMaybe is me but this seems way overengineered
- efilife 2y agoEverytime I read anything about typescript I think of this
- epolanski 2y agoAn example of what you can implement on top of branded types that I want to share with fellow hackers: - 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: https://github.com/gcanti/money-ts https://github.com/gcanti/money-ts
- atonse 2y agoThanks, the currency stuff was a great real-world example that crystallized where this would be nice to have at a core level with typescript to severely reduce the odds of doing cross-currency math.
- spankalee 2y agoAs much as I love branding, I would be hesitant to use it for critical math and where you might want to inspect the type at runtime as with money. 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.
- epolanski 2y agoYou can parse before brand thus enhance and enforce runtime correctness.
- vaylian 2y ago
- aleksiy123 2y agoAnyone else sometimes get more sucked in to perfectly typing stuff instead of writing code. I guess working in the pure logic and formalism of types is just addictive. I love it.
- farnsworth 2y agoYes, if you do a good job it removes a lot of mental overhead for building the feature or refactoring it later. Although if you do a bad job it might add a lot...
- rowanG077 2y agoHow do you ensure someone doesn't reuse a brand string? Seems pretty brittle to me.
- black_puppydog 2y agoStructural typing, and especially for primitives, with these work-arounds being so clunky and not built into the standard library, was the reason I started picking up Rust...
- deleted 2y ago[deleted]
- deleted 2y ago[deleted]
- SergeAx 2y agoSeems like patching earlier language architecture mistake. If you create custom types, you should not use duck typing on them. You should use typecasting, so the programmer would write something like `result = processEntityA(A(B))`
- gmdrd 2y agoIs there something wrong with creating opaque types using this method? 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.