Lies We Tell Ourselves Using TypeScript v2 [eng]
How safe is TypeScript's type safety? How much can you trust your statically typed code? Can you even consider TypeScript's type system "strong"? In this talk, we look at situations where TypeScript fails badly and learn why things have to be that way. We talk about trade-offs, workarounds, and ultimately solutions for all the damn, terrible lies we tell ourselves when using TypeScript.
- Stefan is an architect and developer based in Austria
- He is the author of "TypeScript in 50 Lessons" (Smashing Magazine, 2020) and "The TypeScript Cookbook" (O'Reilly, 2023)
- In his spare time, he organizes ScriptConf and Rust Linz
- Stefan enjoys Italian food, Belgian beer, and British vinyl records
- Twitter, LinkedIn, Blog
Hi, and welcome to my talk, "Lies We Tell Ourselves Using TypeScript." I'm terribly sorry that I can't be with you in person today or live online. I have other duties, unfortunately, but I still hope you find something out of this recording and get some good and nice information out of it. So first of all, again, I'm terribly sorry. I'm sure you have seen a ton of great talks today. There has been lots of great information. There have been lots of new things to learn. And now I'm here, and this is not going to be a fun talk. I will crush your hopes. I will destroy your dreams. You will suffer now for a little bit. And I'm very, very sorry for that. You know, I've done my fair share of TypeScript in the past. So I've written two books on TypeScript. I spent a great deal with the programming language. And I know, you know, I know the intricacies pretty much inside and out. I know where teams struggle. I know where teams fail. I know where teams need to put some extra effort in. Usually, when I start one of my TypeScript books or TypeScript workshops, I start with an example where every line is wrong, like this one.
And this function with species or people. That's a string literal type. Function overloads are fantastic to do that. First, you have the overloads for the API to the outside, and then you have the function signature for the inside. Let's look at the function body. This is the core of the first function. We fetch some data from the Star Wars API and then we parse it as JSON and return the results. If you don't know Swarpy, Star Wars API, it's a great way to have a lot of data. We have a REST API that you can try out, where you can test a couple of things to ensure that you got the CRUD right. And this is also what we're doing here. The stuff from the Star Wars API is wrapped in some meta-information. We don't care about that meta-information. We are just here for the real deal.
So we unwrap it. We call result.json. And we also have some error handling in case something goes wrong when parsing JSON. Great. And last but not least, we have a simple function that takes two errors and concatenates them. Very good. And in the end, we're using our function. We define an empty list, an empty array, and append data via a call to list entries. That's what we do. Two functions. One fetches data. The other one concatenates the results to an existing array. And it's pretty much a standard TypeScript affair. You can argue about coding style. Maybe something is not quite right as it should be. But you know what? That's code that you and I might have written or that some of your colleagues might have written. That's code that ends up in people's codebases. Stuff that passes the review.
This is where you need to do extra checks to make sure that whatever happens is actually what you intend to have. I'd like to call type assertions to be an unsafe operation. So sorry, not type assertion. I'd like to call anything that includes any or that is at its boundaries an unsafe operation. And I think we need to make unsafe operations visible. And there's something that we can use to make unsafe operations visible. For example, indicators like type assertions as meta people. It's much, much more visible that here we are bending the type system to our needs instead of just annotating something and be done with it. Or having a type predicate or assertion signature.
Those are keywords that we then can look for and see that stuff is going wrong. Or that we need to take extra care of things. If you just have an annotation like that, you know, a type annotation like meta people on the type of any, well, how do you find that later on? How do you find all the occurrences where you need to bend the type system to your will when it just produces any and you can override it with a regular type annotation? The other one, the second line here, it becomes clear. Something is afoot. And you need to be aware, and we can make those things visible. This works for a little thing called declaration merging. Interfaces in TypeScript are never closed. You can always add new methods and fields by simply re-declaring the same interface. So here I opened the globally namespace. I opened the body interface, you know, that one that has any in it. And they say the JSON method does not return a promise of any. It returns a promise of unknown.
This is a function overload. With that, the last function overload, this is the one that counts most in that regard. And unknown won't let you cut corners. Any is the cautious sibling of unknown. Unknown is the cautious sibling of any. I'm sorry. Unknown is the cautious sibling of any, which means that they include the same area of values, but you are not allowed to do anything without a type assertion or without making a choice. So I'm making sure that all those properties exist. If I'm adding this overload, I'm getting a beautiful type error the moment I try to assign unknown to a known type. Now I need to act. Type unknown is not assignable to type meta people. Now you can do a type assertion as meta people. This still works. Or you need to check for its properties. First problem, first solution. Let's go to the next problem. The theme is dark and full of errors. Let's talk about this.
But what the developer didn't realize is that if you put "any" in a union with "syntax error", the whole type becomes "any", which allows you to access every property available. That this code didn't blow up at runtime is mere chance. We are not here for chance; we have a static type system. This is not what we want. Again, unknown to the rescue. There's a mitigation for that, a compiler flag where you can turn on unknown in catch variables. This is the default, and you don't need to do anything. "E" or the error will be unknown by default, and you need to narrow it down, as shown in this example, where we narrow it down through an instance of check to "SyntaxError". Instance of checks for errors because errors are objects created by an error constructor function.
When you use the function, you only get types for the first two lines. When you implement the function, you only get information on the last line. And then there are a couple of type checks happening. TypeScript will check that the first two lines check out with the third line so that you don't declare something that you don't implement. Very good. The other type check is that everything you return from that function will be checked against the implementation signature. All good. Two separate type checks. That's true. Usually, you design it so that you are very narrow in your usage signatures and very broad in your implementation signatures. There are no people who are using "any" for all those types because they have a very vast list of function overloads. The problem is, nothing checks if your implementation actually matches your usage.
So you have the type check from uses to the implementation signature and from the implementation to the implementation body. But the implementation body can be anything that matches the implementation. It doesn't necessarily need to follow the semantics of usage. And this crashes. This crashes a lot, and I've seen so many folks failing on that one because it's so hard to see. It's so hard to overlook. And you just remove a couple of lines because your function got simpler or your model got simpler. But you forget to remove the overloads, and now you're there. This is something that you actually need to do. You actually need to test. There's also a way to overcome this, which is conditional types. That's a possible mitigation. With them, you can offer the same parameters and return types as with overloads.
But since they're very complex, the type system can't figure out what you're returning, which means that when you use it like that, you get a beautiful little error here where you say people or species is not assignable to type return t. And now you need to act. Now you need to check on your type. Now you need to do type assertions—all the things that are unsafe operations because we are in unsafe territory again. It's great. Arguably, the complexity is something that we need to talk about. But hey, we are getting somewhere, aren't we? Next one. And I guess this is one of my favorites: the unexpected virtue of ignorance. I'm still baffled that this can happen. We are in TypeScript territory, after all. This is the second function. I have a function called appendEntries. appendEntries takes a list of people or species and concatenates it with another list of people or species that come from a promise. That's just a detail. Fair enough. Since the parameter list is passed by reference, it mutates its list.
So we don't need to return anything—mutable element. But now look at the actual usage. So I create a list of people. And I append a list of people. And I add a list of species. So you might ask yourself: What's the type of the list after appendEntries? It's still people. It's still people. You know? The thing is, for TypeScript, all the contracts are fulfilled. The parameters expect a list of people or species. We are providing a list of people. It's good. It's OK. That's fine. We want to concatenate it with another list of people or species. And we provide another list of people or species. That's fine. So you can take something that is a subtype for something that expects a supertype. That's basic narrowing down. That's Liskov, if you want. But TypeScript has no notion for mutation. TypeScript does not know that something changes. And the type becomes different. There are a couple of things to do that, like assertion signatures, type predicates, but not for situations like that. So the type is very narrow. And we broaden it. Actually, it's very narrow. It's very easy to do that. And we have no way to solve that. And you know, well, you can argue that it's actually a problem of TypeScript itself. But, you know, mutation happens. There's one mitigation. Thankfully, we have mitigations for everything, which is generics. So instead of adding a very broad type, we add a generic type parameter that can be set to a subset of our original type, people or species. The moment we add a concrete value, like the list of people, TypeScript blocks T.
To type and tells us that we can't mix and merge as we see fit. You know, this is the type that we set. And this is the error that we get. Because now the moment we add a list to it, T becomes people. And we get an error that says the argument of type "promise species" is not assignable to the parameter of "promise people". Fantastic. Generics are always a really good way if you want to have something much more concrete based on a variable type. Usually, generics solve all your "any" problems. All your type broadening problems. This is something that I can highly recommend. Please go for generics. And use generic inference, of course. So we have everything mitigated. Fantastic. We highlighted unsafe operations. We got rid of a couple of function overload problems. We now see that we can use generics to make our code more type-safe. It should work, doesn't it?
So, yeah. Use classes wisely. There's lots of debate on whether you should use classes or not. But it's a good way to deal with those things. And well, if you have complex signatures, you can use conditional types. Conditional types are a great way to express yourself. They are very powerful. And you can highlight unsafe operations because they're so complex that TypeScript will mostly evaluate it but never to the fullest. And last but not least, generics. To narrow down to specifics. One of my most important things. You have most different tools in TypeScript. Add a generic here and there. And then you are locked into a more concrete type and you get rid of most of the type problems. But you know what? Four solutions to four problems. And they just contain more lies.
Every solution that I showed you comes with one more or even many more damn lies. Let's take declaration merging for an example. So here I declare an interface called form data. And the moment I use it in handle form, I suddenly have access to APIs that I haven't defined. Where are they coming from? Where is data.get? Where does the .get method with a string come from? Well, you know, form data exists already as a global type. And I merge with the global type. And nothing keeps me from it. And you know, form data is a very generic name. And I'm pretty sure some of you have also written a form data interface in your life. So actually, I brought this up at one of my talks. And people came to me and said, yeah, finally, thank you for explaining that we had exactly this problem.
So yeah, declaration merging is great unless you accidentally merge with globals. Next one. And this is my favorite lie in the entire presentation. You have two classes, class A and class B. Fantastic. Each has a distinct property, A or B. And you have this do something function. That accepts both A or B as parameters. A union type. To access the correct properties, I do instance objects. P instance of A. Then you have type A. And in the else branch, since you only can pass A or B, well, then it should be B. That's control flow analysis. That's what TypeScript does really, really well. So fantastic. That's what we want.
All right. And the last one, that was generics. Generics are fantastic. I love it. Use generics. But you know you can always explicitly annotate generics to a broader type as long as it's within the type boundaries. And if you do that, everything is back to normal again. And, well, what should I say? Please be wise with generic instantiation because once you explicitly set them and make the set wider as they should be, you have the same problems again. So all of our solutions debunked again. Lies, damn lies. I'm very sorry about it. Okay. The epilogue. Again, I'm very sorry. This wasn't a nice talk. I hope I didn't crush your hopes or destroy your dreams. But I think you suffered as much as I did. And you might ask yourselves, you know, what do I think of TypeScript? I've written two books about it. You see all those problems that arise. What do I think of it? And you know what? I actually love TypeScript. Sorry for the cursing here.
But it's still the second best language that I have had the pleasure to work with. And I don't want to work without it. Whenever I do Node, whenever I do browser code, I want to work with TypeScript. It's about making you productive. And I can't argue with the fact that TypeScript makes me tremendously productive. You know, the talk is called the lies we tell ourselves using TypeScript, not the other way around. And I think we tell us a couple of lies. The biggest lie, for example, is that we're here to write code. In reality, we are not here to write code. You're software engineers. Software engineering is about decisions and trade-offs. That's your job. That's our job. That's what you're supposed to do. It's one thing. It's one thing to write this stuff. It's another thing to commit to it.
And you know, all those things, all those little flukes and things that we saw today, they know about it. They know about it. They work on it. They try to make it better. But you know, they made a trade-off. They made a decision. They made a decision that this is the current state of affairs, and you need to deal with it. Because they offer you some fantastic tools, and you need to know their intricacies. You need to know what's going on there. You can only write code if you understand the tool. And that's actually the point that I want to make. Here's the point. Every tool I elaborate on that you use has trade-offs that you need to be willing to take. There's no silver bullet. You know, I've shown you four problems. I've shown you four solutions. Just to see that there are more problems. And that's okay.
That's part of our job: to understand that those problems exist. There's no dogmatic solution for everything. I say use generics. And now you're going to set out and use generics for everything? No. Of course, you don't. It still comes with caveats. Think about if generics are the right tool to solve your problem. This is the question that you should ask yourself. And you know, this also brings me to the whole thing, you know. Oh, wow, TypeScript is such a bad programming language. Who the heck cares? Sorry. Sorry for cursing again. Don't feel superior because you use a certain tool. A language everybody loves to hate is PHP. But here's Taylor Ault, well, the creator of the popular PHP framework, showing off his lamp with that PHP board. So yeah. And then here you have, lo and behold, the Austrian Java fiat, you know. Same vibe. Totally. Who cares? Java people. I know some Java people who are very proud of their choice of programming language. Not all of them.
But, you know, don't feel superior because you use a certain programming language. I'm a Rust developer. Guess what my car looks like. On the other hand, hey, if you look at that, if all you need to do is carry kids and beers, which car does the job? And that brings me to this one message that I want to give you. The last 30 minutes, I was not here to talk. I'm not here to tell you what to do. I'm not here for the five-minute hot takes that go viral on X or YouTube or TikTok or whatever. I'm here to make you aware that nothing is without its caveats. It's about trade-offs and decisions, and it's about productivity. Be pragmatic. Be aware of the trade-offs you make with the tools you choose. Use this information to find and choose the best way possible for you and your team. No solution is a silver bullet. It's not the best thing possible. It's just a solution. It can work for you. Or maybe it doesn't.
So don't go for hot takes and dogma. And yes, I know very well that this phrase was just also a very dogmatic piece of advice. But bear with me. All right. And with that, I hope you had some fun. I hope you learned something today. I hope it makes you think a little bit. You know, you are not the person writing code. Co-pilot is. You are the person committing code. And with that, I want to say thank you very much. If you want to learn more about TypeScript, please check out my two books. TypeScript in 50 Lessons is out now. It was published in 2020. It's your guide to the type system. If you want to understand the type system, this is the book for you. And if you want to have solutions to real-world problems that people face while coding in TypeScript, you get 100 more lessons in the TypeScript Quick Book, which has just been released. I'm very proud of it. It has a beautiful little parrot on top of it. And with that, thank you very much. And, you know, if you need anything, don't hesitate. Don't hesitate to give me a shout-out on X, message me on LinkedIn, wherever you like. Bye-bye.