A Look at tRPC with its Creator Alex "KATT" Johansson
Alex "KATT" Johansson, the creator of popular TypeScript library tRPC, discusses the challenges faced during its development.
Topics include complex generics, the builder pattern, and the importance of types to the API.
In version 10 of tRPC, the goal was to improve performance while maintaining i
Transcript
Interviewer: 0:00 What's up, wizards? I'm here with my friend, legendary TypeScript's library creator, Swedish beer drinker, extremely fun dude, and a very nice dude as well, Alex. How's it going, dude? You OK?
Alex: 0:16 I'm great. Thank you so much for having me.
Interviewer: 0:19 No worries. I'm really excited to talk to you because you've been down the long and winding road of thinking about TypeScript generics and getting deep into TypeScript. You find yourself at the helm of one of probably has the reputation for being one of the most complicated TypeScript libraries out there in tRPC. Why don't you tell us about your journey and sort of what led you there?
Alex: 0:43 I mean, the journey with tRPC is sort of just scratching my own itch. When I started it or in fairness, I didn't start tRPC. I found a very early proof of concept of tRPC that I took over. Before that, I was also contributing to a framework called Blitz.
1:06 The thing I had when I was working, or trying to contribute to Blitz is that the main thing I wanted from it was this RPC thing. That was the magical source of Blitz that you can just have a client component that imports stuff from the server.
1:25 You got that magical thing of, all of a sudden, you're back in PHP days when you have a server-rendered app and use calling function. You have that amazing DX where the database is just there next to you. I was contributing to Blitz a bit. I felt like it had so many things.
1:47 I felt like the project was struggling a bit to become stable. I was looking around for alternatives because I really just wanted this RPC thing. I didn't really know what to call it at the time. It's called an RPC thing. Colin is the creator of Zod. I believe you have interviews with him as well.
2:11 I found his proof of concept, which was basically just like two files, a server, and a client. The server defined some types. Then the client did this amazing trick where you just limport a server thing as a type, which when you import something as a type, I didn't know about that trick at the time.
2:36 When you import something as a type, I didn't know about that trick at the time. When you import something as a type on the client, you import all the TypeScript magic. TypeScript knows what's going on underneath, but you don't have any actual remnants of the server code at runtime. You get this possibility to couple your client and your server.
3:00 I found that. I was just, "Oh, shit. I didn't know you could even do this." This removes the whole compilation step of Blitz that makes it really hard to import to new environments. I just went nuts on the tRPC project. I think, after the first day, I probably got to bed at 4:00 AM or 5:00 AM.
3:26 After the first day, there was not a single line of code left from the original proof of concept. I just spent that whole month hacking on it. Back then, I'd been doing some generic stuff. I'd been doing a lot of pretty deep proof of concepts of type-safe, Next.js forms where you could use your getServerSideProps function to act as a post receiver.
3:55 Then create a Formik form that is type-safe intent. I explored a lot of TypeScript doing that. The level of generics that I know today is 95 percent thanks to tRPC. The process of me building tRPC is like I want to get here. I want the client to be able to do this. Then I just hack around with generics, or whatever, until I get there.
4:29 Then most of the times, because I'm not like the architect type, I'm more of the explorer hacker type. A lot of the times I end up spending a day on something that I used to throw away because I figure out a better solution to the problem or a nicer API design. In that iterative process where I'm just like messing around with types...You see, I struggle not to say [inaudible] right.
4:53 [laughter]
Interviewer: 4:57 You've had one already. You didn't even notice.
Alex: 4:59 What?
Interviewer: 5:00 Yeah.
Alex: 5:01 Oh, Jesus.
Interviewer: 5:03 The dam's burst now, you see.
5:05 [laughter]
Interviewer: 5:05 I was very careful at the start to read it to ask Alex to be very careful with what he said. He's a very voluptuous speaker.
Alex: 5:14 I'm a very unfiltered speaker.
Interviewer: 5:16 Which I love.
Alex: 5:19 It doesn't always go well when you do public stuff, which I'm still getting used to.
Interviewer: 5:26 Let me summarize that. That was amazing because you basically discovered just one feature of TypeScript. You realized I can use this feature to build out an entire, essentially, framework from it, which is the import type directive, or maybe it was something different at the time.
Alex: 5:42 Yeah.
Interviewer: 5:44 That must've been exciting. That's crazy, right?
Alex: 5:47 It was incredibly exciting when I found that. I spent so many nights during that time -- because I've always had a day job on the side of tRPC Guild -- so many nights and weekends just thinking about this, going to bed, thinking about it, waking up, thinking about it. I learned so much.
6:14 One of the main challenges, if we're going to go into that is I find it really hard to have any good resources, some complex things in generics. I think the TypeScript docs are amazing. For the more -- how these pieces fit together, you can go into one thing in the TypeScript documentation and you can find all the primitives that it has. It explains that one primitive well.
6:48 Then when you want to combine them together, you have to figure that out on your own. It's like small building blocks almost. I still...
Interviewer: 6:59 To jump in, sorry, which was, I'm really interested in your approach to building out these TypeScript libraries. How does it work? You said you were more of a hacker type. Do you start with JavaScript first and build out the runtime, or do you start with TypeScript first? What [inaudible] like?
Alex: 7:23 I've been coding for over 20 years. I feel like I can figure out the runtime. I've done that so much before. When I started off working with tRPC, I tried to do both the make it work properly in the runtime and writing the TypeScript at the same time.
7:46 I realized that that is not a good approach because the runtime I know how to figure out always. My approach to working with TypeScript nowadays is I have an idea of something I want to achieve. Then I do a bunch of functions in...I can declare actual functions that I call. All of those functions just throw an error.
8:13 Then I just work with the generics return types of those functions until I can type around in the TypeScript Playground or VS Code and it feels the way I want as an API. Then I go back and backfill the actual runtime. Then I can explore different API ideas without getting too much bogged down in details on how exactly it's going to work under the hood.
8:41 Under the hood in tRPC, most of the things we do in actual JavaScript is pretty straightforward. It's not rocket science. At the end of the day, tRPC is just a record, a map with a bunch of functions that you call then based on a string. It's not too complicated.
9:08 I try not to spend too much time on the JavaScript upfront and focus on the hard bit, which is the generics. If I can get that to work, I can get the other part to work as well.
Interviewer: 9:19 You're focusing on what the consumer of your function feels, how it feels to use, and all of that.
Alex: 9:27 Yeah.
Interviewer: 9:27 You went through a really interesting thing recently, which is you wrote V10 of tRPC. I remember you explained this to me -- you shouldn't think of it as version 10 of tRPC, it's more like version 2 of tRPC.
Alex: 9:43 10 is 2 in binary, right?
Interviewer: 9:47 There we go.
Alex: 9:48 We have to have one nerd joke.
Interviewer: 9:50 [laughs] One nerd joke. That's our one. We'll have one swear and one nerd joke.
9:55 [laughter]
Interviewer: 9:58 That's so interesting to me. What was your approach for doing that?
Alex: 10:03 Yeah. It was difficult. For a very long time, I was thinking I would do just a massive breaking change and ignore that people are using it because tRPC, it's used by a lot of people, and it was back then as well.
10:24 I was, "I'm doing this for fun. I should have fun. I shouldn't think too much about interoperability. I should just have a clean slate and reimagine tRPC a year later from creation, how I would do it with the knowledge I have today." That's not how it panned out.
10:44 The way I started hacking on that was a clean slate. I started a project that I called V10 Playground. There, I just started hacking around with generics. I think, considering all of the good things about the V9 things, but rethinking a few things. One of the big problems with V9 was the TypeScript performance, which is still a bit of a mystery to me, what API decisions lead to what.
11:15 It was pretty clear that it scaled nonlinearly. Every procedure you added to your backend made it much slower. The next one would make it much, much slower. There was actually a hard cap on how many endpoints you can have, which was a symptom of a non-ideal API design.
11:45 Another thing that was a bit frustrating when working with it was some API designs decision are taken early on. The way I was reasoning about middleware wasn't that flexible. For context there, I borrowed a lot of ideas from any other middleware package out there. Generally speaking, you have a middleware on a set of endpoints.
12:13 The way we approach that differently in V10 is that instead of having a middleware on an endpoint, all the middlewares lives on the procedures because then you can have a deeply nested procedure in a router somewhere and have different middleware behavior than the adjacent routes to that.
12:37 In terms of process of doing it, I just created V10 Playground, a fresh repository. It's open source on GitHub. I just started hacking around with ideas, tried to bring in as many OG, tRPC users or developers, or contributors as possible to help me with API design decisions. Then it was mainly me working on it for two weeks while I was on vacation.
13:10 Then 6 to 10 months of decision paralysis in minuscule API details that probably doesn't matter to anyone but to me. I wanted it to be perfect because I don't want to do another breaking change for a very long time. Just obsessing about those details for a long time.
13:33 It's good that it took time because before we actually released V10, we ended up writing a full interoperability mode. You could start adding V10 procedures but still have your V9 API working. Sachin, he's a legend, a very early tRPC contributor who also learned TypeScript while contributing [laughs] to TRPC, which is crazy.
14:05 He wrote a code mode to dynamically change your V9 API to a V10 one, which is a super impressive code mode. Super advanced users.
14:21 [crosstalk]
Alex: 14:21 Yeah. It's not easy, the way we changed middlewares. He made a way where this middleware would be abstracted and hoisted at the top of the function automatically. Then it would rename everything recursively. Very cool stuff. The process was that just hacking without any JavaScript runtime, "This is the API design I want. This is the feeling I want."
Interviewer: 14:53 Yeah. I'd love to have a look at that, actually, because I've got the V10 Playground available to us here. If we look in a few of these functions, we've got server, index.ts. We can dive into a tRPC. Inside here, we've got procedure, create procedure. Then inside create builder, just throw error unimplemented. This is what you're talking about...
Alex: 15:17 Yeah.
Interviewer: 15:18 is just throw a bunch of these errors. You do have all of the types written out.
15:23 [laughter]
Interviewer: 15:23 You feel that types are essentially the most important part of the API. They define what's possible. Would that be fair to say?
Alex: 15:33 Yeah, 100 percent. It's in the name of tRPC, TypeScript remote procedure call. Without the types and without that being great, there's no point of tRPC. It's really a TypeScript-first-designed API as well. I don't do anything in tRPC that can't be typed well. That impacts the API design as well. If I can't figure out...
16:11 [crosstalk]
Interviewer: 16:12 Have you got any specific cases in mind where that led you to a certain thing?
Alex: 16:18 Yeah. As an example, I find, and many people find tRPC a bit annoying to set up. That is because we have this root T object where we initialize tRPC. It's a bit annoying that you have to have that root object and build everything from that.
16:43 The reason we have that in the tRPC is that there are some things in your applications that is shared across all procedures. For instance, the context object. What happens when a request reaches your server? You often want to have a request context.
17:06 That's typically where the session lives or the calling user lives. You want that to be accessed everywhere. In order to make sure that the inference just works in a procedure, you need to have some way of referencing that. You don't want that to be global.
17:30 The way we do it is that we have an inner tRPC object that we can then inject different generics within that are then inherited by everything that builds on top of that.
Interviewer: 17:43 Could you show me how that works here, let's say?
Alex: 17:46 Cool. This is a real project. You have inner tRPC. You can hover over that T. You see some crazy stuff if you hover over the T variable. Here you see that we have something called _config. You see it's called root config.
18:10 In your root config, you can have a few different properties. You have context, meta, error, shape, transformers, etc. We can try now to change the inner tRPC. This is using something called a builder pattern. I believe you cover that in your course.
18:28 If you call inner tRPC.ctx or context. I don't remember if it was ctx or context actually. It's actually a context. Then there, it's not called with any actual argument, but you pass in a generic there. You need to pass in an object there. You can pass in foobar or something.
Interviewer: 18:53 Let's say, user, firstName: string or id: string.
Alex: 18:58 Yeah. Now, if you hover that T variable again, I hope that's going to be different.
Interviewer: 19:04 Wow, look at that.
Alex: 19:08 That's how we build out this root configuration. Then thanks to that now living in that root T object, we can then use that T object to build a procedure from that will know that the context has a user on it.
19:26 In your greeting query there, you actually use context. If you hover on that, you will see that that's...
Interviewer: 19:33 There it is.
Alex: 19:34 Yeah.
Interviewer: 19:34 If I remove some of this stuff and I actually return the thing, and what I can do is with this, I can say, console.log and then (ctx.user.id). Whereas, if we remove this and comment it out, then that will actually be an error, because context is just an object, which is I assume the default.
Alex: 19:58 Yeah, exactly. It's just an empty object as a default.
Interviewer: 20:02 Wow. What led you to use the builder pattern for this example?
Alex: 20:09 I mean, easy answer is that it was the only way I could figure out how to make it work, because I didn't want to have globals. One alternative is you can declare interfaces. An interface can override a global interface. I could do something like this where you have a tRPC context. We declare that our context is something. Exactly what you're declaring here.
20:44 Then we could just work through that as well. Then you couldn't do things like having multiple tRPC servers. Some people actually have multiple tRPC servers. I don't personally. It feels like an anti-pattern to use a global like this. Instead, we're using the builder pattern and create this concept of a root object. It has pros and cons.
21:17 When you do the TypeScript definition output of your server, it gets really noisy right now because that context is duplicated everywhere it's used, like as is. You will have that root config in your TypeScript definition files if you actually look at those. When you think about performers of TypeScript, those things actually start to matter. It's frustrating to deal with.
Interviewer: 21:47 I haven't really touched TypeScript performance in Total TypeScript because I personally don't feel very confident teaching it because it's so wizard. It's so crazy, isn't it?
22:01 [crosstalk]
Interviewer: 22:01 It's hard to figure out.
Alex: 22:04 Yeah, it is crazy. You have tooling from TypeScript where you can introspect stuff, but it's really hard to decipher. At this point, I don't actually use that much. I've run it up a couple of times just to be, "This is how it works. Hopefully, I'll never need it." The thing I do do in tRPC, which is a very non-scientific way of doing things, I have this script. You saw it there, generate big F router.
22:37 [crosstalk]
Alex: 22:38 Guess what the F is for?
22:39 [laughter]
Interviewer: 22:40 I saw it earlier. I can't believe you're even sneaking it in -- generate big F router.
Alex: 22:47 I do a very layman's way of type testing. I use generate an F ton of endpoints and just use VS Code and be, "Does this feel good still want to have 3,000 procedures?" Another thing is does it even build? Because in V9, it stopped building at 500, 600 procedures. Here, it doesn't seem to have the same hard limit. Here we just create a bunch of procedures.
Interviewer: 23:28 That's pretty cool. Could we go back to something that I'm interested in again, which is you've used a builder pattern up here for TRPC.context, create. I don't know if this is new for V10. Was this always part of tRPC?
Alex: 23:46 No, we did a bit differently. In V9, we have used classes, but they were also using a builder pattern. We had a router class. On that router class, you could call, for instance, a query and that query would be added to a big fat generic and return a new router with that generic added.
24:16 It also used a builder pattern, but in a different way. Now it's more, I don't know how to call it, but maybe like a DI builder pattern more.
Interviewer: 24:30 I assume DI means direct input?
Alex: 24:32 No, no, no...
Interviewer: 24:34 What does it mean?
Alex: 24:35 Dependency injections.
Interviewer: 24:36 Sorry. DI in musical terms means direct input.
Alex: 24:39 [laughs] Yeah, yeah, yeah.
24:41 [crosstalk]
Interviewer: 24:41 messed up. Yeah, yeah, yeah. Interesting. Dependency injection, OK. Tell me more about that.
Alex: 24:50 Now, it will be injected everywhere. With other pattern, it would only affect anything that would be defined afterwards. If you added an error formatter, because that's something we can do at tRPC too. You can have like inferred error formatting.
25:14 If you added error formatting at the end, you wouldn't be able to access that before or so. It would get fiddly with the order of things. Now, it's a bit clearer that this is the root definition. This is everything that is shared across your whole application. Then the rest is your actual endpoints and how they map to each other.
Interviewer: 25:39 We've got this public procedure then that has a .input and a .query on it. This is using the builder pattern, right?
Alex: 25:47 Yeah.
Interviewer: 25:48 I would love to use this as an excuse to just get your thoughts on the builder pattern. Could you explain it like I'm five using this as an example?
Alex: 25:59 Using this as an example makes it easier. Basically, here you have a publicProcedure, which is just an object, mind you. It's just an object. When you call input on that, you return a new object with different behavior. You can do that recursively, because every time you call it, it returns itself, but with some more type information.
26:31 We have the base procedure that doesn't have any types and then you call input on it, then it's another builder that has some more types. Then you can call input again and it will combine those inputs that you already had. It won't work in this case, because you're returning a primitive, but if you have an object, those objects will be merged.
Interviewer: 26:55 That's amazing.
Alex: 26:57 Every time you call it, you will return a new instance on itself, until you call the ending thing that returns the procedure itself. The ending in our case, it's either a subscription or query or mutation. I don't know whether I started with subscription there, but you can either have a query, a mutation or a subscription as a procedure.
27:28 That's the last thing you do. In that, you write the logic that is unique to this procedure. Everything else as part of that build chain can and should be reused across multiple procedures.
27:47 You can do a base procedure that has authentication, where we check that the user is authenticated, the context object includes a user, and then you can infer a new context to the following builder instance that is created. In that way, you can make this really nice, reusable snippets of code and build out your application that way.
Interviewer: 28:27 The idea here is that we have public procedure, which is it looks like a procedure builder. Then we have inputs. You call inputs, which returns another procedure builder with more generics in it. Then you call query, which doesn't return a procedure builder. It returns a build procedure, the final instance of the thing.
Alex: 28:50 Yeah, that should change name. It should be just procedure or result or something. That's what it does.
Interviewer: 28:58 We can't do inputs on this or something and continue the chain.
Alex: 29:02 Right now, it stops there. Maybe, there might be a use case for that in the future. The procedure is built up to that point, and then you end it. It completes the craziness.
Interviewer: 29:21 Why would you do this like this instead of having, let's say, an object with inputs on it or a query on it like this? Why use that specific type of syntax?
Alex: 29:34 It makes it easier to make things reusable. We have considered this pattern as well as a shorthand way of doing things, because I do agree that an object reads better. If I were going to be honest, in the way, when you have that, I think it reads a lot better. We have considered doing that as a alternative syntax, so maybe, the query could have exactly this.
30:06 The problem with this is that you can't do a base procedure and reuse it. If you only had this API, anytime you define a middleware, you would have to define that in every procedure.
30:22 Let's say you have pretty complicated middleware structure. First, you check authentication. Then you check that the...The procedure was called with an organization ID. Let's say we're doing a multi-tenant SaaS application. Then you usually have requests that are unique to an organization.
30:43 First, you typically check, are they authenticated? What's it called within an organization ID? Does the logged in user exist in that organization? You can combine all of these to an organization procedure and just reuse it.
31:05 Without the builder pattern, you can't achieve or that I know of in nice way, achieve something like that. We've opted to have an only builder pattern API, but we might mix and match in the future because I honestly am not happy when I look at big tRPC routers right now.
31:27 I feel like it's hard. You can't expand and collapse procedures nicely or the builder pattern doesn't play well with expanding and collapsing in VS Code at least. That's a bit of an itch that I want to scratch.
Interviewer: 31:47 Fascinating. When you have an itch that you want to scratch in general, that leads to more innovation, which is interesting. I've lost my notes, because my computer crashed halfway through.
Alex: 31:58 We keep doing it. That's how I got to talk about generics.
Interviewer: 32:02 That's interesting because one thing that I thought from using the builder pattern and seeing it used in tRPC was that this kind of inference wasn't possible any other way, because the way you're building this is you have your inputs here.
32:20 Then, the query takes in the type from the previously declared inputs, so this one here, for instance, is a string because you're validating that it's a string inside here. You feel that this is just a design choice. You could go to this more object syntax, if you wanted to.
Alex: 32:38 We had that in V9. It worked exactly like that.
Interviewer: 32:43 Wow. That's amazing.
Alex: 32:46 I think we can look at the V9 docs. If you don't go to docs.trpc-v9, we can look at...Now, I don't remember how that exactly looked like, but define a router or example procedure. Yeah, yeah, yeah. Here, I don't know how I sent it to you, but we can...
Interviewer: 33:07 Docs V9 QuickStart.
Alex: 33:08 Sorry, I have a bit of a cough.
Interviewer: 33:11 No worries. So do I. Define a router.
Alex: 33:15 I sent you something on the chat in the streaming things. It looks exactly how you requested there.
Interviewer: 33:30 There it is. Input and then resolve. That's it, but you're still using the builder pattern in general to create this.
Alex: 33:38 Yeah, exactly.
Interviewer: 33:39 You've got your mutation, and you've got your list here. I remember.
Alex: 33:42 That was the pattern that really didn't scale at all, because every time...I never actually dug into why it doesn't scale that much, but my interpretation is that when you create this router, add a mutation, then you have a full representation of a router with a createMutation.
34:06 Then you add list. Then you have a full representation of the previous object, plus this new object. I think that is what killed it when it got big. Also, it wasn't just a flat object.
34:25 Every time you saved something in any router, it would have to look through this whole builder and recompute everything, or at least that's how it felt, but I'm still a noob in TypeScript performance, which might be worrying for people listening to this.
34:54 I've always had the attitude. You solve problems when they arrive, and you learn the tools needed them to, or that's when I learned stuff. I never really picked up a programming book and learned that way. I learned by having the idea and executing it or having a task at work, whatever that we want to do.
Interviewer: 35:19 Let's take one step back then, because we've got about five minutes for me to finish. I would love you to do one more explain like I'm five. I would love you to explain your mental model for generics. This is like pre-tRPC in terms of your learning process, right?
Alex: 35:37 Yeah.
Interviewer: 35:38 You were doing all of that crazy getServerSideProps. Obviously, it probably feels like generics are in your blood right now. How would you explain it to someone who does not get it, does not understand how they work?
Alex: 35:58 It's a very difficult concept to explain in simple terms. It's like the matrix, you have to see it for yourself. It's like you define a function that has behavior that can change, depending on how you call it.
36:25 That's how I think about it in layman's terms. Of course, at runtime, it will always be able to change because you have your statements, whatever, and branches of that code. It allows the types to behave differently.
36:44 The types of system that behaves differently, depending if you call the function with a string or a number or an object or an object that looks like this or the other. That's my mental model around it.
37:01 You can see it with a very simple example of, you have a function that returns a T and you take T as an argument, and then you return the argument. You'll see that when you call that, you actually have the right type.
37:17 Where if you would code that in a language that doesn't have type generic support, let's say, the type would either be a string or a number, you would have an argument that is a string or a number. That function would always return string or a number.
37:38 It wouldn't be able to narrow it down that we know that you call this with the string, therefore the return type is a string. That is magical. The things that that enable is really, really good, especially in terms of a developer experience, and the speed you can work, and auto completion, and all of that.
38:07 To me, as a tangent on this, the generics that we have in tRPC and open source libraries like that, that deep of knowledge you need to build those libraries is usually not that useful when writing a normal application code.
38:29 To me, when I write application code, if I end up writing too much generics, it's usually a smell to me that this is something that should live in a library. It shouldn't be this complex to use the type like CRUD code.
38:45 I think in my world of thinking, or my way of thinking, generics doesn't really belong...They do belong in application code, too, but there's a threshold of generics you should probably not pass in application code because it keeps the barrier of entry too high. I like my application code so a junior can collaborate and contribute to it at any given point.
39:25 I do not believe that really, really complex generics necessarily belongs in an application for that very reason. If they do, there should be an isolated piece of code that has really clear description of why it exists and what problem it's solving.
Interviewer: 39:50 When I interviewed Orto, he had a really good phrase for this, which is you have library code. You have application code. Then you have the utils folder in the application level of TypeScript code.
40:07 If you do have crazy stuff in your application, that's where it's going to live. That's where you build your abstractions that take the getServerSideProps, map them to a Formik type, or things like that.
Alex: 40:16 Exactly.
Interviewer: 40:17 That's where the clever stuff lives. I think then let's finish there because of my computer crash, we're a little short on time. Thank you so much for joining along. This was fabulous to hear your thoughts and get you to actually explain some of the stuff that's in tRPC.
40:33 It's so cool that you're so excited about this stuff because I am too. I feel like it enables so many cool things in TypeScript. Thank you so much, Alex.
Alex: 40:42 Thank you. It was really fun.