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 Baumgartner
oida.dev
  • 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

Talk transcription

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.

But it does parse; it's JavaScript. It doesn't produce any runtime errors. It just doesn't work as intended. And TypeScript can find all those errors pretty easily. Look at it. Those were 10 errors that TypeScript could see by just looking at your source code. This is great. This is what TypeScript is for. And this is what we're actually looking for. But not today. I'm not talking about this today. I'm talking about the total opposite. This here. This is some TypeScript code that's well-typed and complete. It compiles. It runs. It throws errors at runtime. You know, that kind of errors that TypeScript is supposed to prevent. In just a few minutes. But that's two functions. One fetches data, and the other one concatenates the result to an existing array. If that's too much for you to read, there's lots of heavy type annotations in it. Don't worry. Let's go through it step by step. We have some function overloads, which is nice. I love function overloads. I think function overloads are the answering hero of TypeScript. Where you can define an API to the outside world that tells you exactly what to expect when you call this function.

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.

So you might just have to ask yourself, what do you think can go wrong? Well, I'd say everything. Actually, every line of code in this example goes wrong. Where does it go wrong? Everywhere. And when does it go wrong? Well, one by one because JavaScript is single-threaded. Brings me to my most favorite joke about JavaScript, knock knock, race condition. Who's there? Anyway, let's go through all those problems. Step by step. Welcome to chapter one. A tale of fetch. There's one damned lie hidden in this statement. I'm doing a type annotation. I see what comes back from result.json is of type meta people, and this line only works because the json method from the interface body returns any. And any is the happy-go-lucky type in TypeScript. Everything is possible.

And if you say that data is meta people, then TypeScript is like, well, okay, whatever. You know best. It's your responsibility and your program goes boom. And there's lots of hidden "any"s in TypeScript, which, you know, are there for convenience's sake. It's good that we have them. Otherwise, you know, TypeScript wouldn't have gotten this adoption because there's also lots of "any" in JavaScript. But this is one thing where you can just annotate whatever you like, and TypeScript will be okay with it. And you need to be aware of that. What we see here is TypeScript at its boundaries. TypeScript defines a type model or types of your model, if you will, that work pretty much pretty well when you are within the TypeScript world. But you rarely have software that is just within your own boundaries. You have software that reaches out to APIs from other pieces of the backend, for example. You have user input. Everything that is IO API calls. You name it.

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.

Any catch clause and error handling here. It's very interesting. What's going on here? We catch for syntax error or any. Why did the developer do this? Well, you know, in JavaScript, everything can be thrown. You can throw errors, objects, even numbers. JavaScript is a bit like my kids. They can throw tantrums, and you don't know where they are coming from. JavaScript throws unexpected errors, and you have no clue where they're coming from. With everything being throwable, it's a false assumption in TypeScript to say that you're catching one particular error. This is not possible. You only have one catch clause, and this catch clause needs to catch errors that can come from other APIs or happen anywhere. This is why somebody included a little "any". The code reads right, doesn't it? It's a syntax error coming from result.json or it's any because anything can happen.

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.

Then you can do an instance of check as well. Good. Great. Now we can know that we are actually dealing with the right error. Let's look at problem number three. That's plenty of problems for not many lines of code, isn't it? So we have 10 lines of code, three problems already. Okay. The metrics overloaded. I am a huge fan of functional rules; I think they are fantastic, but now I want you to turn your eye to the functional rules. They tell me that I get a promise of an array of species when I put in the key "species". They tell me that I get a promise of an array of people when I put in a key of "people". So far so good. But in the code, I only return people. In the signature, it tells me I get "species", but I only return "people". And you know what? TypeScript will think it's "species". How is that possible? What's happening here? And this is one of the biggest caveats in functional rules. I'm very sorry that those exist, but we need to deal with it. Let's see what those functional signatures actually mean. We can pull apart those three lines of code. You actually have two different kinds of functional signatures. First, you have the functional signatures that define the usage. This is the API to the outside. And all you see here is that you get "species" from a key "species". You get "people" from a key of "people". That's all you see there. And then you have the implementation function signature. This is just your regular JavaScript function signature. Here, we create a union type of "species" or "people". And it returns an array of "people" or "species". You know? That actually, in your modeling of this function signature, checks out.

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 now we have ten lines of code, four problems. We found four fantastic solutions. Please enter the final chapter. Lies, damn lies, and TypeScript. So the story so far. What did we do? We used declaration merging, a fantastic feature in TypeScript where you can open up existing interfaces, put some new content in it, patch assumptions that the type system made for you based on the reality that actually exists. Overwrite anything with unknown. Please use the TS reset library by Matt Pocock. It's fantastic. It does deal with exactly those things. It's great. You make it more type-safe without breaking typeshift itself. Declaration merging to patch assumptions. We were pretty more strict with our catch clauses and the error types. Now we could do what we needed to do. Instance objects. Instance objects are really good if you have classes and create objects with it. It's as far as a type system and type checking that JavaScript has built in. And TypeScript mirrors it perfectly.

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.

But then again, TypeScript is a structural type system. Which means that you can pass elements which have the same shape as A, not necessarily B. But they are an exact instance of A. So yes, for TypeScript, the shape of this object that I pass here and an instance of class A is the same. And it's correct. In a structural type system, it's correct. But the instance object comes from JavaScript. And it only works on instances, not shapes. So yeah, this will cause a problem. And we're just logging it. We log undefined. What if you, I don't know, calculate something with it? You get an error. You get a runtime error. This breaks. So yeah. Instance objects are great until you realize that TypeScript is structurally typed. Next was conditional types.

So yeah. Anybody can tell me what this does? Anybody? I can't. I can't. I've written this. So this is my code. I guess I needed to create overloads for occurring function. I think that at least what curried and overloads tells me. But what does the rest do? Where is all this coming from? And they're powerful. I love conditional types. They're very powerful. Library authors love it because they can create really, really good types that explain the API so well because JavaScript allows for very funky APIs. And now I have a tool to explain them. Great. But ask yourself, is the complexity worth the benefits? Is it something that you would add to your project? And be safe that you know what's happening afterwards? Basically, that's TypeScript's answer to regular expressions. Same complexity. You need to have documentation to understand what's going on.

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 I mean, literally, commit it into your GitHub repo. And you know who knows pretty good about trade-offs? TypeScript and the TypeScript team. It's an explicit non-goal of TypeScript to be provably sound. That's a pretty bold statement for a type system, I have to say. They want to strike the balance between correctness and productivity. It's a goal to make you productive as hell, making all the little trade-offs that come with the type system that sits on top. It's a job of a programming language like JavaScript. And that's okay. The TypeScript team are some of the smartest people that I've had the pleasure to meet. It's tremendous what they do to formalize a programming language like JavaScript. It's supposed to go haywire when you just look at it. But that's also software engineering.

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.

Sign in
Or by mail
Sign in
Or by mail
Register with email
Register with email
Forgot password?