Kent C. Dodds: Having fully typed web app just drastically improves developer productivity in ways that not a lot of other things can. Noel: Hello, and welcome to PodRocket, a web development podcast brought to you by LogRocket. LogRocket combines session replay, error tracking and product analytics to help software teams solve user-reported bugs, find issues faster and improve conversion and adoption. You can get a free trial today at logrocket.com. I'm Noel, and joining us again today is Kent C. Dodds, a speaker, teacher, trainer who is actively involved in the open source community, spends a lot of time working on Remix and talking about Remix. He's been on a couple times, I've talked to him before. Welcome back to the podcast. Kent C. Dodds: Thank you so much. Yeah, I have been on a couple times. I really appreciate that you keep inviting me back. So I guess it wasn't that bad. Noel: Yeah, yeah, no, you're a good guest to have on. Easy to talk to and a lot to talk about, so it's always a pleasure. If you would, if people missed those episodes or they're just tuning in, can you give us your little one or two-minute spiel about yourself, your work and what you're spending time on right now? Kent C. Dodds: Sure. I am a full-time software developer and I work for myself, primarily as a teacher. So I do develop software, I write a lot of code, but I spend a lot of my time teaching people how to write quality software. And my goal is just to [inaudible 00:01:32] my positive impact on the world by helping other people to maximize theirs. And so the things that people might know me from, testingjavascript.com and epicreact.dev. I'm currently working on epicweb.dev, and that is basically everything that I know about web development in a course. The article that we're going to be talking about today is a post on the epicweb.dev that people can take a look at. As far as other things I'm involved in, I am pretty active in open source and was a part of the Remix founding for a little while. I was a full-time teacher, then joined up with Remix for a while and then went back to full-time teacher, right around the same time when Remix was acquired by Shopify. And still very much involved in that. And I am co-organizing Remix Conf as well, so if anybody's listening to this and you're interested in coming out to Remix Conference Salt Lake City, I'd love to see you there. Noel: It sounds like you're juggling quite a bit. But yeah, let's dig into the talk. Fully Typed Web Apps, is that the title, or I guess, what is the title? Kent C. Dodds: Yeah, Fully Typed Web Apps is the title of the blog post. And just some thoughts that I've been having about what the web is moving to, or at least developing for the web as my observations as a Remix developer, or developer who works on Remix at Remix apps, and seeing what the general trends in the web world are. Typing has kind of become a big thing. Noel: Yeah, I feel like the fully typed end-to-end thing isn't a totally new concept, but I feel like we're at a new iteration of it right now. I guess when you're talking about end-to-end type safety, what do you mean specifically, and why is it so valuable? Kent C. Dodds: Yeah, so JavaScript is a typed language. There are types in JavaScript, but there's no type checking in JavaScript itself. Certainly no runtime type checking. We've always needed to have linters and now we have TypeScripts and flow typed as well that run basically like linters. So they're still not runtime, they run statically, so they do this as part of your build process or whatever. And so because of the fact that JavaScript has no runtime type checking, we haven't really been able to get end-to-end type checking or type safety in the web for ever, until the last few years when TypeScript is kind of picked up. And the trick with typing on the web is the web has a lot of boundaries to your application. And in the blog post I talk about a couple of these different boundaries that we have. I have this line here where I say, "The secret to fully typed web apps is typing the boundaries." So you can think of a couple, like parsing some JSON out of local storage, that would be a boundary. So any IO or input/output sort of thing. Reading inputs from a form, so the DOM is another boundary that we have. So both reading things out of the DOM as well as writing. And then the web Fetch API, also another boundary. You're making HGTP calls, reading things from the URL, even, like search parameters or if you're using some sort of param-parser, that is technically a boundary also. And then we have the node side of things in JavaScript as well. So reading stuff from the file system, making database queries, and when we receive a request, parsing out the data from that request. So all of these different boundaries need to be typed. Because when you call a function, that function can have a certain signature and it's really easy to spend a day learning TypeScript to type simple functions. And then when you call it, if you call it with the wrong parameters, then the TypeScript compiler or the type checker that you're using is going to say, hey, this isn't incorrect, or it is correct. And you don't really get that when you run into these boundaries. And so you have to kind of patch that up. And the reason is because you might say, well, I know that this file exists and I know the contents of the file, so why can't TypeScript just know that, or trust me or whatever? But at any moment somebody could change the contents of that file and there's no way for your program to prevent that. For a long time, it was very difficult to get this full end-to-end type safety that we have because there are just so many boundaries on the web. And now that we've got the technologies that we have now and fully end-to-end typed web apps is such a beautiful developer experience, makes for much more bug-free applications. Noel: Yeah. I mean, is it reasonable to think of boundaries like this is anywhere data is coming into the runtime, anywhere your code is pulling in data from an external source, whatever that may be? Kent C. Dodds: Yep, precisely. You can consider that from the backend perspective as well. When you said, "anytime data is coming in," I was like, well, maybe also out but not quite out. It doesn't matter, you send whatever you want to. But if we're talking about a full stack application, then yeah, your node server or whatever other server, that request that the front end is sending to it, that's not data output, that's actually data input to the node server. So yes, precisely. It's anytime data comes into your application from an external source. Noel: Gotcha. So what are the tools that devs can use to make this more safe? When you're parsing a file, for example, how do you know that the data in that file or whatever data in JSON is the type you expect it to be or your code expects it to be? Kent C. Dodds: Yeah, super question. So first of all, I think it's really good to just embrace the fact that you do have boundaries to your application. And so understanding the different types of boundaries that you have is important. And then finding the right tools that you can use, or maybe not necessarily tools, but just strategies that you can use to patch up those boundaries is useful. There are three different things that I say that you can sidestep these limitations of the boundaries. The first is to write type guards and type assertion functions. This is just built into TypeScript and I believe that flow has this capability as well. Though TypeScript has definitely won in this contest of the typed JavaScripts, and frankly of anything that compiles to JavaScript. Nothing is even close to TypeScript. And in fact, I would suggest that we are now at the point where a majority of new applications that are being actively developed are either using TypeScript or moving to TypeScript, which is just phenomenal, personally. So anyway, you write type guards and type assertion functions. The second is to use a tool that generates the types. That gives you really great confidence, like 98, 99% confidence that the types are right. So type guards and type assertion functions, that's like a hundred percent. If the code path gets through these, then we know for sure that the types are correct. Using a tool to generate types at some sort of build time or something doesn't get you quite that same level of confidence, but it's pretty dang good. You should be able to trust it most of the time. Noel: So it's easy, right? You get it set up and you're like, oh, your types are just getting spit out. Yeah, it's really convenient. Kent C. Dodds: Yeah, precisely. You have to measure the level of convenience versus the amount of confidence. And I would say for the most part, generating those types is pretty good. Then the third one is to help inform TypeScript of your conventions and configuration. Because a lot of the time you've got your program that is expecting URL in a certain way, and you know that your parser is going to parse it so that you pass in this URL and it gives you back an object of all the params, and you just know how that works. And so you can somehow inform TypeScript of that convention and how that works so that you can get some type assistance there. And that works pretty well, provided that your conventions are well enforced, and most of the time they are. If that fails, then it's just a regular bug and regular software anyway. And so those three approaches, the type guards and type assertions, generating types and informing TypeScript of your conventions are the main categories of how we can patch up these boundaries. Noel: And so I think at the end there you spoke to an interesting nuance to this, is if a runtime error occurs parsing these things, at some point there's nothing one can do, right? And we're into the realm of runtime error handling. Is most of this post covering how to handle that, or are you mainly focused on what to do during dev time to make sure that your assumptions are correct and you're not introducing bugs in inadvertent ways? Kent C. Dodds: So, runtime errors are going to happen for sure. And with the exception of the type guards and assertion functions approach to this, that's kind of a special case. But the other two approaches of informing TypeScript of your conventions or generating types, they don't give you quite the same amount of confidence because your program is programmed assuming that those things are correct. And so if they fall out of date in some way, then your program is no longer technically type safe. The types will check off and everything's fine, but the data flowing into your application is going to be different from what the types said they were, so you're probably going to experience a runtime error. And so that's an important thing to call out when you're taking those approaches so that you can make sure that you handle unexpected errors well, and those things will happen. By definition, they're unexpected, and so you can't really plan anything other than just that you handle them. But as far as type assertions and things, those are things that you're planning for. You say, if this object has an ID and a first name and a last name and an address, then I know... It's kind of like if it looks like a duck and sounds like a duck and walks like a duck, it's probably a duck. And so we can treat it like a duck and that's fine. And so in that world, you say, if this is a duck, then I can do this thing. If it's not a duck, then I will handle that. And so now you're in the realm of it's not quite a runtime error, because although it may be unexpected, at least you have to write the code that says, what happens if this is not what I expect it to be? Now, assertion functions are a little different because that's going to throw an error. A type guard, we'll be able to check. An assertion function will just throw an error and says, hey, this isn't what I expected. And so yes, again, you are going to want to handle those gracefully, but the fact is that you can and that you're thinking about it when you're building these. But I think it's important also to just call out why this matters so much as well. Why do we even care? Because it is not a trivial amount of effort for the tool authors. As far as the users of these tools, you definitely want to be aware of it and thinking about it, but it's not as hard for you. The tool authors are the one putting in a ton of work to make this work nicely. So the reason this is so valuable is just because a really good way to reduce the amount of cognitive load that you have as a developer, tracking the data as you're writing your code. So you get this argument, what are the properties it has on it? Before I was using a typed language... I guess I shouldn't say that because I learned Java first. That was my first thing, that was a typed language, but totally different experience over there. So when I was just doing JavaScript, what I normally would do is I'd just put a console log, whatever the thing was, and then run it and then see what it looks like so that I can do my stuff. Really did not work well, especially if this was a response from an API or something. And now I have to call the API to see what the data looks like and maybe notice that it's different from the docs or something too. So having fully typed web app just drastically improves developer productivity in ways that not a lot other things can. So, if anybody's listening to this and they're like, "Oh, that sounds like a lot of work," just believe me, it is going to save work in the long run. This is an investment in your productivity and way, way more valuable than your selection of font or theme in your editor, which people have no problem spending plenty of time setting up. So yes, I think that it's super-duper worth putting a little bit of this effort into. Noel: Just a quick pause here to remind you that PodRocket is brought to by LogRocket. LogRocket can help you understand exactly how users are experiencing your digital product, with session replay, error tracking, product analytics, frustration indicators, performance monitoring, UX analytics, and more. Machine-learning algorithms service the most impactful issues affecting your users so you can spend your time building a better product rather than hunting through tools. Solve user-reported issues, find issues faster, and improve conversion and adoption with LogRocket. Yeah, people kind of lose the forest for the trees a little bit when we're down here. Everybody's talking about type checking and why we're doing all this, and simplification and justification and all that stuff. But yeah, I think it's one of those things, it's hard. Once you're in the realm of doing it for long enough, you kind of forget what it was like before, when you didn't have all these nice guardrails making sure things were the way that they were. I think there is a little bit more in that space, though, of error handling, like what the role is. And there's a whole path we could go down about error handling is control flow and all that stuff versus where that should belong in a well-structured code base. But I think maybe we should dig a little bit in just for people who haven't spent time in this. You're talking about the differences in type guards and assertions and how those might manifest in the code you're writing day-to-day. Can you expand on that a little bit? Kent C. Dodds: They're very related, and in fact you can even use a type guard to help you build an assertion function if you want to. But to bring it down to the easiest level I can think of, let's say you have a variable and you're not sure what it is, but you think that it's a string and you expect it to be a string. And so maybe this is something that you made a request to some endpoint that says whether or not a service is healthy, and all it sends back is a string of "okay" or "error" or something. And so you're pretty sure you know what this thing is, you just want to make sure that it is either error or okay. And so the type guard would say it is basically just variable triple equals string okay. So you say, if that, then inside of your if block, boom, you know that variable is okay. And so now you can continue with whatever needs to happen for that. And then you could switch on it and say case, okay, case error, whatever. And then the default would be, this is unexpected. Whoops, something is weird, throw an error, display it, something. And so that's a basic idea of a type guard. Now, it's pretty frequent or pretty common to turn this into a function. And so you have a function like is user. And so then in that it's going to accept what you think might be an object, but you don't really know, so you just say function is user, obj:any. So you just say it is like anything. Or you could do unknown, but that can be a little bit tricky and I don't think we need to get into the weeds for that. So this is actually one of the few places where I say any is actually better than unknown just because it's a little easier and it doesn't actually make any practical difference. And then you would say :obj is user, user type. And what that is saying is the return type for this function says that the object that is provided is this type. And so inside of the body of that function, you're going to say type of obj equals object. Obj is not null because null is an object, because that's ridiculous. JavaScript is hilarious. And then you're just checking properties like type of [inaudible 00:17:03] ID equals string, type of [inaudible 00:17:05] first name equals string, and all of those things, as specific as you care to get the level of confidence you're looking for. Now I can say if is user, may be user, then I'll be certain that object is a user and TypeScript can use that to know that I can auto-complete stuff for people and I can type check. So when I say maybe user dot first name, that will auto-complete for me. And then in the else block I can say, something weird is happening, console log or send a report or something like that. So that's what a type guard is, it's just a little bit of code that helps narrow the type down from something that's really wide, as unknown or any, that's like who knows what this thing is, down to something really narrow. This is called type narrowing. So then assertion functions or type assertions, even, it doesn't have to be functions, it's just a type assertion is one that will throw an error when it is what you don't expect. So that's why I say you can actually build a type assertion out of the type guard because you could say if is user, may be user, whatever. And then in your else case, you throw an error, it says this isn't a user. So that would be a type assertion. I actually hear people talking about that type of thing as a type assertion, so there may be people who disagree. But more commonly you would find people talking about type assertions with regard to functions, because there's a special syntax for this, at least in TypeScript. So a type assertion in TypeScript is written where it's actually kind of similar to the way that you write a type guard function, where you say the return type asserts, and then obj is user or whatever. And so then what that allows you to do is you say, well, if we get to the point where this object is not a user, then the rest of this code shouldn't even run. I don't even want the rest of this code to run, and so we'll just throw an error instead. And so what that allows you to do is just to say maybe require a user, I typically will name my assertion functions or prefix them with require. Just so it visually says, hey, I require this to happen. And so yeah, require a user, I pass the thing that I think is a user and then the rest of my code can just assume that's a user. Because TypeScript knows it's impossible to get past that function call if this object is not a user. And then there's actually a really helpful utility that I use quite often called Invariant. It's a generic utility that allows you to pass... I forget what their first argument is called, but it's basically just a thing that you expect to be true, so results triple equals okay, or the error instance of capital error or whatever. And then the second argument is the error message that you want to have thrown if that is not the case. I use this fairly frequently to do a pretty quick and easy type assertion, especially for simple things like strings and things that I'm like, I am really, really positive that this is what it is, but we'll just make sure and have TypeScript help us with that a little bit. Noel: Do you find yourself putting those assertions on every boundary, or are you thinking about the context of a boundary, the likelihood of certain errors there? Or do you find the type system necessitating that you have an assertion or a type guard every time you have data coming in? Kent C. Dodds: I think that it really depends on what your goals are and how important type strength is in your system. So if you're okay throwing an any here and there around your application because this is just a little toy that you're throwing together, then that's fine. It's not something I would want to maintain in the long term, but that's okay. But the fact is that around any boundaries that we've discussed, if you don't do some sort of type assertion like this, then you're going to run into problems. So, of course there's typecasting, so you can add as whatever at the end of whatever expression and just say, I'm pretty sure this is it. But I really try to avoid using as, or any generic. Sometimes people write a function that accepts a type and then all that function does is it adds an as whatever type you passed in at the end of it. So you're fooling yourself without realizing you're fooling yourself, and I'd rather fool myself and know that I'm doing it. And so I don't like using those kinds of generics, but I would much rather just have as and whatever and just say to whoever's reading it, "I tricked the compiler and I know about it, I've made this decision and I know it's wrong." But yeah, if I'm working on something where type strength is a really important thing to me, then yes, I have strict mode on, I have no implicit any, and so I do have to deal with all of the types at every boundary. And this is probably as good a time as any to mention there are really great tools that enable runtime type checking, which the type assertions and type guards are. And the best one that I've found, the one I enjoy the most, is called Zod. It's grown in popularity very quickly, it's awesome. Zod.dev is a fantastic tool that makes it really easy to build schemas that will have a particular shape or union. You can basically think of Zod as a mechanism for taking what you have in your head as a type and turning it into something that has runtime characteristics, so you can do this runtime checking. Zod is a fantastic tool. If anybody's thinking, "Wow, that sounds like a lot of work to write a type assertion function for every single boundary of my app," then you're probably at the point where you should look into Zod. Noel: I think the impulse, too, for a lot of devs is like, well, it sounds like I'm doing the same kind of assertions. I would be using the same kind of assertions all the time, or the same kinds of assertions that everyone would be doing on every query string parameter ever parsed into [inaudible 00:22:42]. It's like, yep, reach for one of these. That's their whole purpose for existing, and can save one a lot of time. How about, I think that's a decent segue-way, how about the stuff we alluded to earlier, like type generation and informing of types? Let's talk about generation first, because I think we're right on the precipice there of how that might be time-saving. Kent C. Dodds: The best example I can think of that I have personally used is Prisma. Prisma is a node.js ORM that's not like any other ORM you've ever used. I know a lot of people cringe when they hear the word ORM, but Prisma is not like the bad ones you've used, I promise. It's amazing. And one of the things that it does is you have a special schema file with specific syntax for Prisma for defining your tables and non-properties of those columns and all that. And then Prisma will take that and generate a client that is fully typed, with a bunch of utility functions and stuff for finding and deleting and updating all that stuff. And that is incredibly well-typed. And so it not only generates the runtime code for querying the specific tables, so you have your Prisma object dot user dot find menu or whatever. And so there's the runtime characteristics there, but it also generates the types based on your schema as well. And so when you say, "I want to select the ID and first name of all the users in my database who signed up in the last week," then the result that comes back is going to be an array of objects that have an ID of type string and a name of type string as well. And so it's able to do that because it's generating the types. And so there is a point in your development experience where after you update your code base with this new schema and you've generated the migration file and everything, you have this generating of the client phase where it will also generate the type. So that's a good example of type generation. Noel: And there's a bunch of tools I'd implore our listeners, explore the space in which you're working, in whatever your tool chain is. There's probably someone who's doing some work here that's trying to use whatever context you're in to do their best job of gleaning what those types are and informing your code and is easy to use as possible. But again, it is domain-specific sometimes. So go out and look around, you can probably find something. Kent C. Dodds: Or you can make it. Noel: Yeah, or you become the maintainer. Yeah. How about informing, which was the last bullet point of those initial three you listed? Kent C. Dodds: So, informing of conventions, of the three, this is probably the easiest in some ways, but also the least secure or strong. So, generating your types, you're good so long as there are no bugs in the generation process and you don't make any changes without regenerating the types. And so if you have the right things in place and you know, okay, we upgraded the schema, we have to make sure we regenerate. And you could even write a PR bot that makes sure that there's never a change to the schema without regenerating. Actually, the client is regenerated at build time, so at least for as far as Prisma's concerned, that's how that works. But anyway, when you're teaching a convention, this is pretty safe. You just have to make sure that anytime you change the convention or anything, you make sure that you teach it the proper convention. So again, as long as you have things like no bugs in the convention and you're matching your convention properly, then this can be really awesome. The example that I use in the blog post, I am talking about a single file in a Remix application where we have our loader function that's responsible for loading data, and that can come from any source or whatever, but in my example, I'm pulling it from Prisma. So the cool thing that makes this completely end-to-end is that Prisma generates the types. So I patch up the boundary with the database and then Remix has this really cool feature to patch up the boundary between the backend code and the front end code. And that boundary's the network. And the loader gets the data, and then it's supposed to send it on to the client. So the trick is, because we've got a network there, there's actually no way to know for 100% certain that something didn't happen in transit. The sun can send a beam of energy and hit your wire at just the right point to flip a bit. Weird things can happen and I don't think that we should really plan for those things. I mean, you want to handle unexpected errors for sure, but I think that it's okay to assume that in a Remix context, if I return something from my loader, then I should be able to consume it in my UI as it was returned. Other things that are actually more practical issues that could arise is if you have some load balancer that perhaps changes the data, or you've got maybe not a load balancer, but some service that stands between your server and the user. So those kinds of things can happen. And so that's why runtime type checking is just a lot more strong. But I think that it's an okay compromise to make to say, I'm going to have a little weaker type safety here just by a tiny, tiny fraction weaker, in favor of just how easy this is to get this type safety. So anyway, the useLoaderData is a hook that Remix exposes that will basically reach into Remix and grab the loader data that is specific to the route that you're on, the route that's rendering the component that's using this hook. And that useLoaderData is a generic function, so it accepts a type between those two angle brackets. And so for that, you specify type of loader, and so you're effectively passing the type of your loader. And thanks to Colin McDonnell who wrote Zod, an amazing developer, does really awesome stuff, Colin took the time to be able to infer what the return type is from the loader so that we can feed that into the TypeScript system. So that when you're getting the return value from useLoaderData, you actually get that value fully typed based on whatever the loader is returning. And so you get full type safety across the network boundary. And then you're using some typed backend or database like Prisma and you can get type safety from the database all the way to your UI. And with the ability, I have a demo in here that shows the ability to even do a find or a rename where you just use the editor to say, "Hey, rename this variable," and it renames everywhere, both front end and back end. It's amazing. It's very, very cool. So that experience, again, it makes for a really awesome developer experience and you avoid a lot of bugs. Noel: I feel like that's almost as good a point to send listeners home with as any. Is there anything else you want to point people towards or implore them to check out in this space in particular? Kent C. Dodds: Yeah, I think another example that I think is important, I won't spend too much time on it, but typing your router is something that React Router doesn't currently have itself. There are tools that allow you to do this, and even there's a linter plugin that will make sure that you're not linking to pages that don't exist in your route configuration, and type generation tools that'll help you with that. But something that's really cool is what Tanner Linsley has done with TanStack Router. Basically, you configure the router by calling his special APIs, and then it's able to derive all of the possible routes based on that configuration. So this is another example of informing the compiler of the available types. So people should take a look at that example, and that will definitely come to React Router as well eventually, because it's just such a good idea. Other than that, I think that this is definitely something that's worth investing in. I do have an example, like a demo, if anybody wants to play around with what it's like to have full type safety from one end of the stack to the other. I'll just, spoiler alert, it's amazing. And if you haven't experienced it yet, then I strongly advise you give it a shot. Noel: Well, thank you so much for coming online and chatting with me, Kent. It was a pleasure as always. Kent C. Dodds: Hey, thank you, Noel. I really appreciate you giving me some of your time. And yeah, I hope listeners go take a look at epicweb.dev. It's going to be a pretty big effort and I think it's going to be a really good resource for people in the future. So give it a look. Noel: Of course, of course. Take it easy. Thank you so much. Kent C. Dodds: Bye-bye.