I was wrong about typescript part 1
6 points by amadu
6 points by amadu
This article has several weird misunderstandings of TypeScript. They don't necessarily invalidate the overall point but I'll try to enumerate so that anyone reading isn't misled.
In typecript’s not so strict type system, you can cast anything to the
anytype. And from theanytype, you can cast it to whichever type you wish.
This is true but it's also true of unknown type, which is the type-theoretic "top". TypeScript only lets you do casts directly up and down the type lattice, which is why this works. (Incidentally, this also means you can do as never as Foo, which is not the idiom but works just as well.) The any type is actually much worse than this, because you can use a value of type any as a value of any type without a cast, not merely cast it to anything. any is legitimately bad, but not for this reason.
The compiler does not complain about this, despite it being inherently wrong. This is something that will fail at runtime. If we were to access the
idor
It is not inherently wrong, it just means that you are explicitly claiming to know better than the type checker. Which is always going to be happen in at least some cases. Also, in this specific example, accessing those properties would give you undefined, not crash.
Here is another example forcing typescript to not be strict. The
!!syntax:
The syntax is !, not !!. And as with the previous point, there's nothing wrong with being able to override the type checker.
Let’s take a look at another example.
This example doesn't pass the type checker. In particular, the
function returnSafeUser(user: User): SafeUser {
return user;
}
doesn't check, because User is not a SafeUser. You can't make this check without actually returning a SafeUser or explicitly overriding the type checker (or exploiting an unsoundness in the type checker, of which there are several). I have no idea what point this section is trying to make.
This is fundamentally different from languages like Rust, where the type system can actually guarantee that if you claim to return an
Option<T>, you genuinely can’t returnnull, the compiler enforces the contract at the language level.
As I understand it, in Rust, like in TypeScript, you can say you know better than the compiler. In Rust this is done with the unsafe keyword, something like
unsafe {
std::mem::transmute::<*const u8, Option<&'static u8>>(std::ptr::null())
}
(Maybe Option is a bad example because the None representation is actually null? But something like this, anyway.)
This is precisely equivalent to the TypeScript examples, just louder and more discouraged.
There's also the Any trait, which can be used similarly. Although it is also verbose.
Downcasting from Any causes a runtime type check, like downcasting in Java or Go. It does not bypass / override the type system. You also can’t interact with Any beyond what little operations it provides, it’s not a dynamic escape hatch.
The any type is actually much worse than this, because you can use a value of type any as a value of any type without a cast, not merely cast it to anything. any is legitimately bad, but not for this reason.
It's really unfortunate that the typescript developers used any to refer to the dynamic type, and unknown to refer to the top type… for me at least my intuition is that this is back-to-front. :(
It's kind of an accident of history (so, very JavaScript-y!), because originally they didn't have a distinct top type at all, so any served that role and got that name. unknown was only introduced in version 3. never was added in version 2.
That said I personally do like unknown for top, since it represents values that the type system has no information about at all.
I agree with this, and the misconceptions and the general “negative” view I had of TS were the reasons I was wrong about TypeScript! There is a lot to learn still. More so than what I mention in part 2.
I do find it interesting how most of the issues come from the developers themselves rather than the language :)
I often see error handling come up in typescript, and it makes me a bit sad to see that people running into this reach into replicating Result as a wrapped object when hitting these cases.
Typescript's big advantage for "down the line" enterprise-y code for me is how you can do type discrimination just through random property checks. So it's perfectly reasonable to have something like
getUser(id: string): {name: string, email: string}
| {error: "notFound", details: string} {
// ...
}
The core point here being that because your result type and error type are shaped differently, the type system is going to help you out, but you also just get back the object you wanted in a successful case.
This does have some nasty footguns (Result<T, E> = T | E means that Result isn't composable like a wrapped type is), but if you're writing application code these are basically not hit just by the fact that it's very rare for some business object to be shaped like your error object.
The main reason I'm against replicating the same ADT structure found in Rust or Haskell is that, unfortunately, TS does end up being JS, a language without pattern matching, without ? shorthands, without a bunch of other stuff... so the ergonomics of pure replication lead to some awkward code. But you can have a smooth API and have TS still help you out a lot anyways! Tagged litteral discrimination means you don't need to wrap things in many cases.
If you're going to do tagged literal discrimination, you need to be careful or else you can run into unsoundness:
function getUser(id: string): {name: string, email: string} | {error: "notFound", details: string} {
const email = "some@email.biz";
// try to get the user's name and fail... but we got the e-mail so let's be helpful
// and stick it on the error!
return {
error: 'notFound',
details: 'oh no',
email
}
}
const u = getUser("id");
if ('email' in u) {
console.log(u.name.concat(" is the email"));
} else {
console.log(u.details);
}
This shows up more in 'real' (non-Result) ADTs. The solution is have the discriminator be a property that's set to a different literal in every branch (so type Result<T, E> = {ok: true} & T | {ok: false} & E, or a kind field if you're doing a more general ADT with a bunch of branches) and then always use the value of the discriminator.
Yeah this is definitely a footgun (TS does help you out a bit here where if you had written const error: ErrorType = {...} it would have complained about excess properties in the literal)
I do think that I also don't have this issue in practice because I early-return on errors and would check the error case, not the success case.
The soundness issues are real, and I am happy with how much TS checks for me, but it's also because I write my code in a certain way and don't do "silly" thing (like in your example, while email is being passed in it's not present in the type signature at all on the error case).
In practice I think I have a "natural" descrimination key that I can already use. I guess my point was more "you don't need to add another discrimination key if you already have one" but... yeah.
TypeScript’s main advantage was being able to model ECMAScript’s type system while being able to “just pull in” existing JavaScript by gradually adopting typing or putting types at the boundaries. But that meant all of the oddities of a language built in 10 days—along with 10 years of patterns in an ecosystem without these patterns.
To me, what is annoying is its “good enough” + ubiquity pushed basically every alternative to the extreme niches at this point. Some years ago tasked with making the TypeScript code “more Haskell-like” in its error handling & such (using fp-ts at that time) as they had Haskell on the back-end. A week later a junior, completely unfamiliar with an Option/Maybe type completely side-stepped the types, choosing to check for null on the Option to unwrap & lose the type + its combinators. I don’t blame the junior—I blame the tech lead for shoehorning TypeScript as “easier/cheaper to hire” when they clearly wanted the less flexibility & more rigor of GHC.js, PureScript, js_of_ocaml/Reason, & so on. The cherry on top was how utterly unidiomatic & unergonomic the code had become.
But now a few years later, I hear the same complaints from these types of folks—& instead of investing in these smaller languages or compiler target to make them better/competitive, they have gone from on-the-rise for their audience to being completely shut out by a TypeScript monoculture for “good enough”-but-I’ll-still-grobble. …Granted I’m largely salty as this was space I liked building in as well as collecting a paycheck, but it’s basically all dried up now.
I think it's true that many languages provide an escape hatch like any in TypeScript.
I also think I'm seeing something different from the author for the last example. It doesn't compile on the TypeScript playground
Pretty much any statically-typed language has escape hatches, in my experience. There are always limits to how much the type system can express, and some times when you know more than the type-checker and have to override it.
I disagree that this “removes the benefits of using a type safe language in the first place.” It's not all-or-nothing. If you design your code well you don’t need these escape hatches often, and they are just another place where you have to rely on your intelligence (and code review) to ensure something it safe; it’s no different than e.g. “I know that at this point n is less than a.length, so a[n] is safe”.
This was an oversight on my end. The SafeUser was meant to be a Subset of User. This playground has the behaviour I inteded to showcase.
Is it? The only one I can think of which is not a dynamically typed langage with a type system retrofit is C# with dynamic.
Most languages have a top type you can convert everything to and from, but you can rarely do much or anything to that top type directly, you generally need a checked downcast to get something you can interact with meaningfully.