10 ms·
This is pretty close to type branding (newtype wrapping for the Haskell-inclined), though using template literal types is pretty novel. Normal brands look somet
by headbee 3y ago
This is pretty close to type branding (newtype wrapping for the Haskell-inclined), though using template literal types is pretty novel. Normal brands look something like this:
type Brand<BaseType, Brand> = BaseType & { readonly __brand__: Brand };
type FooId = Brand<string, 'FooId'>;
function fooBar(asdf: FooId | 'foobar'): void { }
fooBar will only accept the literal string 'foobar' or a true FooId, but not any arbitrary string. FooId would then come from a function that validates strings as FooIds, or some other part of the app that is an authoritative source for them. Brands extend their BaseType so they can be used anywhere their BaseType is used, but not the inverse
- ssalka 3y agoI prefer the Brand solution, it works well for existing ID sets that you can't easily migrate to have an actual string prefix
- throwanem 3y agoAlso for values that aren't strings at all.
- blue_pants 3y agoThe meaning is different though. Brands convey intent, UserId brand would allow only other UserId brands, but with string literal types "any" type that matches 'user_${string}' will do
- WorldMaker 3y agoIf you want to make this easier to keep private between encapsulation boundaries the additional suggestion is make sure the Brand type extends symbol: type Brand<BaseType, Brand extends symbol> = BaseType & { readonly __brand__: Brand }; const FooIdBrand = Symbol('FooId'); type FooId = Brand<string, typeof FooIdBrand>; function fooBar(asdf: FooId | 'foobar'): void { } Using a private shared symbol your authoritative validation/sources can share your brand symbol and no one else can create one without using your validation. Private symbol brands in this way become the closest Typescript gets to "nominal types".
- jwalton 3y agoUnfortunately this doesn’t work, at least not from a type safety perspective, because even without access to the symbol, nothing stops anyone from doing `let myFooId = 'foo' as any as FooId;`. You could detect this at runtime, but type safety is compile time.
- kristiandupont 3y agoSure, the TS type system is not sound but the idea is not to stop "bad guys", it's to help you realize you are doing something unintended.
- presentation 3y agoAgreed, for instance in our codebase we just make all type assertions a lint error demanding a justification, as well as flat out banning the any type. But anyone is free to write shoddy TypeScript.
- jwalton 3y agoThis is very true, but "helping you realize you are doing something unintended" works just as well with a string as with a symbol.
- WorldMaker 3y agoRight, hence "closest to" in my description. Typescript's role ends at compile time and it can't/won't stop bad actors at runtime. Typescript tries to make it easier for good actors to do the right thing more of the time. That said, the other benefit to using private symbols like this is that they are also easy to enforce at runtime, because symbol visibility is enforced at runtime (you can't create the same signal by hand somewhere else). It can be as easy as something like: console.assert(id.__brand__ === FooIdBrand) (That still won't stop the determined hacker in the console dev tools, if they can see a symbol they can create a reference to it, defense in depth will always be a thing.)
- Jasper_ 3y agoThe easiest way I know of is declare const isMyID: unique symbol; export type MyID = string & { [isMyID]: true };
- deleted 3y ago[deleted]
- bassdigit 3y agonice!
- lf-non 3y agoIt is also convenient to use a unique symbol for the brand (declare const brand: unique symbol). Then we can combine multiple brands in the same type and if we don't export that symbol type, we simply dont have a way to access the brand property at runtime.
- lifthrasiir 3y agoIn my experience branded types are relatively more fragile than normal types though. IIRC they badly behaved with infer types in particular, and it was quite hard to work around. This solution seems more versatile. (Of course, I want to see a built-in branded type support in TS as well.)
- lolinder 3y agoThis solution only works with strings, whereas branded types can be used with numbers as well, or any kind of object that you want to add stricter types to without modifying the runtime value. I haven't observed any issues with branded types and infer—is there documentation somewhere about the problem?
- lifthrasiir 3y agoAs others pointed out, TypeScript sometimes reasons `string & object` or similar as an impossible type and can turn it into `never` at any time. I don't exactly recall whether `infer` triggered that or it was a separate issue, but that was a major problem in my experience.
- presentation 3y agoFWIW I’ve been using branded types for years and never had this issue.