I 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.
I 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.
How do ids of different types accidentally get into a place they shouldn't be? Is this simply a case where someone mistakenly passes along a property that happens to be called "id", not noticing it's an account id rather than a member id (as in, an implementation error)?
This kind of mistake is quite easy to make, especially when somebody writes a function that takes several IDs as arguments next to each other. I've seen it happen a number of times over the years
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.
As oppose to a zero cost build time check, that's 3 expensive runtime DB checks, plus an account and a member could have the same ID, so it might run anyways but on the wrong DB rows.
You made two points that are correct in theory, but in practice those lookups would/could take micro-seconds, and the bug would be detected within minutes in a production environment. And it would take five minutes or less to patch.
We could argue if having 1-3 code cowboys is better then a large team. If the code cowboys can produce higher quality, quicker. They will be difficult to replace after many years though.
Yeah this is exactly why using primitive types for ids is bad. If you encode it in the type system then you cannot make this error and don't need to check it at runtime.
Most bugs are found at runtime. The static analysis mostly finds silly errors that are easy to fix. A great type system do make it easier to write spaghetti code though, and you can have one letter variable names - naming is difficult.
Yeah, or what's more likely is that you originally used an AccountID everywhere, but then you decided that you want to use a UserAccountID. So you update your functions but you miss a spot when you're updating all your call sites.
Or 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.
If you have a distance argument typed as a number, for example, and you pass miles instead of km.
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.
Happens a lot with junction tables ime. e.g. At my last job we had three tables: user, stream, user_stream. user_stream is an N:N junction between a user and a stream
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:
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.
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.
Rather, I think Typescript's philosophy of not having a runtime is simply wrong. Other statically typed languages retain type information at runtime. I don't understand why that was so important to explicitly exclude from Typescript.
If we needed a special Typescript runtime to use Typescript it would become nearly useless. The vast majority of Typescript becomes JavaScript running in Node or V8.
The web is stuck with JavaScript, and Typescript is a tool to help us write maintainable JavaScript.
Using any JIT language (most implementations of python, Java, ruby, JS, etc) in WASM means porting the runtime to WASM and then having the runtime (in WASM) parse and JIT-compile your code incurring a double-whammy of performance degradation (WASM runtime compiling your JIT language runtime and then your JIT language runtime compiling your actual code).
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.
It wouldn't be very useful, since a TS-in-JS runtime would have significant performance overhead, and a TS-in-WASM runtime would have very expensive JS interop plus cross-language-GC hurdles. Might be less bad with WASM GC.
With horrible overhead, and in the case of WASM, lots of serialization and API glueing (DOM is just somewhere near the tip of the iceberg) woes, maybe?
Would be a fun thing to do for sure, but never as fast as the APIs built into the browser runtime.
So it’s Just JavaScript and the risk of adopting it is basically zero.
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.
Static typing information isn't retained at runtime. e.g.
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.
> Other statically typed languages retain type information at runtime.
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.
The decision between structural and nominal typing has nothing to do with whether you retain type information at runtime.
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.
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.