I'll dive into the internals of Nuxt to describe how we've built a TypeScript-first framework that is deeply integrated with the user's IDE and type checking setup to offer end-to-end full-stack type safety, hints for layouts, middleware and more, typed runtime configuration options and even typed routing. Plus, I'll highlight what I'm most excited about doing in the days to come and how TypeScript makes that possible not just for us but for any library author.
Making Magic: Building a TypeScript-First Framework
AI Generated Video Summary
Daniel Rowe discusses building a TypeScript-first framework at TypeScript Congress and shares his involvement in various projects. Nuxt is a progressive framework built on Vue.js, aiming to reduce friction and distraction for developers. It leverages TypeScript for inference and aims to be the source of truth for projects. Nuxt provides type safety and extensibility through integration with TypeScript. Migrating to TypeScript offers long-term maintenance benefits and can uncover hidden bugs. Nuxt focuses on improving existing tools and finds inspiration in frameworks like TRPC.
1. Introduction and Background
Daniel Rowe, NUXT maintainer and open-source library author, discusses building a TypeScript-first framework at TypeScript Congress. He shares his involvement in various projects, such as Fontane, Magic Regular Expressions, and Elk. Daniel invites the audience to connect with him on his website at roe.dev and offers assistance with questions, help, and open source contributions. He also mentions his location in the UK and the unpredictable weather.
How TypeScript Works Hello. It's a real pleasure to be here at TypeScript Congress talking about making magic, building a TypeScript-first framework. My name is Daniel Rowe. I'm an open-source library author and maintainer of NUXT, which is a framework for building web applications. I'm also involved in some other things from Fontane, which helps reduce cumulative layout shift with custom web fonts, to Magic Regular Expressions, which is a re-imagination of what projects could be like, based heavily on TypeScript magic, to Elk, which is a client for Mastodon, a distributed social network. I'm also a Google GDE and a Microsoft MVP. You can get in touch with me on my website at roe.dev. If there's anything that I can help you with, I would love to. I'm accessible if you have questions, or you need help, or you want to contribute to open source. For whatever reason, it would be really nice to say hi. I live in the UK, in the northeast of England, in a lovely rural setting, by the banks of a river, in the shadow of an ancient forest. I have three cats and a dog, and this is where I am right now. And probably, when you get in contact or send me a message, you'll find me in this exact kind of situation. It's been raining a little bit today, but it's also sunny. So that's a pretty good indication of UK weather, if you're not familiar with it.
2. Nuxt Framework and TypeScript Integration
Nuxt is a progressive framework built on Vue.js, providing a seamless developer experience with best practices and configurability. The goal is to reduce friction and distraction, allowing developers to focus on their code. The use of TypeScript at a core level aims to enhance this experience, with a focus on being the source of truth, leveraging inference, enabling augmentation, and revealing project truth to the end user.
And you're probably wondering what is Nuxt, if you haven't come across it before. It's a progressive framework built on Vue.js. Vue is the rendering layer for the front end, but it's full stack. So it also has a server engine called Nitro, which is now a framework of its own and used by other meta-frameworks as well.
One of the key things for Nuxt is the developer experience. So it's all about making something that is easy to use, that has best practices built in, but doesn't impose a high barrier to entry, but at the same time is configurable and allows you to extend it as you need, as you grow and have different requirements, it should grow with you.
A few years ago, we started to rebuild Nuxt. And one of the things that we worked on that we wanted to do was think about how we could make Nuxt even more magical. And I think magic is a really important characteristic of a framework that I would want to use. I know sometimes it can be used in a pejorative sense, but I think magic is something that we want in terms of developer experience. And for me, it's really these two qualities. It's about reducing friction, keeping you in a single context, and about reducing distraction. So, adopting a more minimalistic principle.
So, wherever you switch context, if you're a developer and you're writing code, ideas are flowing through you and out, if there's something that stops you from that, whether that's you have to go to the documentation to find out something, or you need to check in another part of your project to understand whether you can do what you're trying to do, that is going to slow you down and create a feeling of frustration. And the same is true, I think, with distraction. So, when you find yourself having to maintain complex bundler configuration or to repeat code over and over again, even when you open a new component and you see 20 lines of imports at the top before you even see the code that you want to look at, all of that, I think, can get in the way of flow, state, something that makes you feel productive and to enjoy and flourish in what you're doing. And so I think magic is one of the things that we can do, can provide us framework offers that can dramatically improve the experience of developers.
So, when we came, as I was saying, to rebuild Nuxt from the ground up, for us, it was about thinking, how can we use TypeScript to make this kind of magic? Because building the framework, writing it in TypeScript is pretty much table stakes. But what could we do if we aimed to have TypeScript built in at a much more core level for the framework itself to be TypeScript native? And I came up with these four different possibilities, and you might have others, and more might go to me later. But I thought these would be helpful guide to go through. And the first is that it has to be designed to be the source of truth in a project. The framework needs to be designed for inference, so it takes best advantage of TypeScript to do what it can do. It needs to be designed for augmentation. And it needs to be designed to reveal all of the truth of the project as much as possible to the end user. So, what do I mean? First, designed as a source of truth. So, the moment you clone a new project in any framework, often you'll find that there's a page in the documentation called using with TypeScript. And in this page, there's then a tsConfig. And you can go to the page and copy and paste it into your project. Or maybe you have a template where it's already there. And from that moment on, your TypeScript config starts to diverge from the reality of the library.
3. Source of Truth and TypeScript Integration
It's important to establish a single source of truth for a project. Nuxt takes a different approach by being the source of truth and generating a tsConfig that reflects that truth. It allows customization of aliases in the Nuxt config and provides the right path configuration. Nuxt also benefits from the Vue initiative called Vola, which is a cross-framework and cross-IDE editor integration for TypeScript. It wraps TypeScript and can be run from the command line, making it versatile and not tied to a specific editor. Nuxt leverages the power of TypeScript for inference and aims to connect TypeScript and the user's code.
You might change it a little bit here, change a little bit there. Or maybe the library adds a new feature. And I think it's really important to think, what is the source of truth for the project? So, for example, it's possible to configure paths in your tsConfig aliases effectively. And it's really important they match the actual values that your bundler understands.
And there are different approaches for this kind of thing, ensuring that there's a single source of truth. And so some frameworks, Avit, for example, will read your TypeScript config and try and respect what's there. But we've adopted a different approach with Nuxt. Our aim is for Nuxt to be the source of truth. And we generate a tsConfig that reflects that truth. So you can customize aliases in your Nuxt config, that's important because we need to know about them. It also means we can expose them to module integrations. And then we generate a tsConfig that has the right path configuration. Because we understand sub-path export conditions, we can set the bundler configuration, the module resolution to bundler. Because we support importing JSON files, we can say that that's allowed as well. And so it makes sense for us to be in charge of that, I think.
I also think, and this is not something that we actually did as NUX, more one that we benefited from, but there was a Vue initiative that started by Johnson called Vola. And it's a fantastic editor integration for VS Code, at least that's how it started, enabling TypeScript to understand single file Vue, single file components, which look a little bit more like code, and single file Vue, single file components, which look a little bit more like HTML than TypeScript. But it basically wraps them and transforms them and makes TypeScript understand this. But Vola was built in such a way, it's now cross framework, so it's not just Vue, and it's also cross IDE, so it's not just VS Code. And it's built in such a way that it wraps TypeScript, so you can even run it from the command line, which means that everything that we've done, we've tried to do in such a way that it is not specific to a single editor, so it's not VS Code specific, it's TypeScript specific. Which means that we are able, so JetBrains for example now uses Vola. But even if they didn't, it would be possible just using Vue TSC on the command line to get full type support for every feature that we built. And I think that's a better approach in general, than creating, for example, a plugin that does some magic specifically in an IDE. So wherever possible, we've tried to use things like define functions that provide types rather than rely on a magical plugin that just gives hints in your editor. But it's not just about setting the source of truth, that's pretty much the start. We've tried as much as possible to use the power of TypeScript for the things it's good at, such as inference. So rather than writing lots and lots of files to disk with lots of information, as much as possible, we just want to connect TypeScript and the user's code. So here are two examples. First, end-to-end type safety. So in NUX 2, we had the ability to have server endpoints with a sort of node express connect style pattern.
4. Type Safety and Integration
In Nuxt 3, developers have full control over the server endpoint and the fetcher function, enabling the provision of types. The signature has been modified to directly return the JSON value, with support for other data types. This approach facilitates inference and allows for type hinting, providing type safety without the need for additional client creation or wrapper functions. It's a seamless integration with TypeScript and the endpoint, offering type safety with a standard web fetch.
So you could access the node requests and response objects. You could call functions like resend, you could pass a string, and that's going to be sent back to the end, to the user who's consuming it. But of course, they have no type safety for that. How could they? Unless you create a custom client, maybe you're using something like TRPC, there would be no way to type this.
And we thought for NUX 3, we actually have full control of this. We have control of the server endpoint, it's in your project. We also have control of the fetcher function that's going to fetch from it, so we can actually provide types. So we did this. We moved the signature to one which doesn't just call a method, like resend, to one that actually directly returns the value that it... The JSON value that it returns. We also support other things like returning a stream or a buffer or things like that, or null to return a 2 or 4 response. And there are a lot of other features and reasons why we built it this way. But one of them is that it really enables very nice inference. So we can simply say, for example, to TypeScript, this key, like this path, API test, corresponds to this file and its return type. That's all we need to do. Then we provide a wrap-around fetch, which is nice in other ways. So it automatically passes JSON, for example. But it also can provide types. And so we can type hint the possible routes in your app, and you can select it. And we can also provide you the actual types of those as the response. Not because we've done any hard work at our end to generate those types, but simply because we've connected TypeScript and the endpoint. You don't even have to do any work as a user, you don't have to create a client or a wrapper or call some special function. It's just a normal web standard fetch. And you get type safety for it.
5. Inject Pattern and Extensibility in Nuxt
In Nuxt2, there was a common provide inject pattern for adding global singletons. However, when types are involved, type-safe access becomes important. Nuxt leverages inference and a define function to provide type-safe access to injections. As users create more plugins, they can simply be added to a list. Nuxt's philosophy is to be extensible, with hundreds of community-maintained modules available. Module authors can customize and control types and the type environment by adding their own templates to the Nuxt build.
Another example, in Nuxt2, we had a common provide inject pattern, where you might do something like add a global singleton in your app. So, maybe an auth helper. And we had this pattern where you could inject, you could call an inject function and pass in something that then gets injected into your app. And then you could use it. But of course, the moment types come into it, it's not going to work because what you ideally want to do is have type-safe access to this helper. And it's up to you as a user to create the types for that and create an augmentation.
And actually, as a framework, we don't want users to have to write types. We don't want them to have to write augmentations. So, we were able to use, again, inference, so the same kind of pattern where we have a define function and a return type. And doing this, either having a function or an object with functions within it, this inference pattern works really well. Because all we need to do, again, it's a little bit more complex than the API, but we just have to point the file to some types that do some massaging of the data. And that's all we need to do. Then you have type-safe access to all of your injections.
And so, as the user creates more plugins, the only thing we have to do is add them to a list rather than do any kind of transformation or anything like that. So we take advantage of inference where we can to make best use of TypeScript's power. But then we also design specifically for augmentation. And as I said before, this is really core to Nuxt's philosophy. We want to be extensible. It's one of the things that makes us, I think, unique. It's the community and the ecosystem that have come around it. But it really has meant that we do have to build Nuxt in a way that can be extended. So we have Nuxt modules, which are integrations. We have hundreds and hundreds of them. Some created some core modules, but most are community-maintained, some private within agencies and companies that use Nuxt. And they are everything from integrations with CMSs to authentication to data fetching to caching to very customized and specific modules. You can see some of them at nuxt.com forward slash modules. And users can do the same kind of things.
So what does it look like to let users and module authors customize and control the types and the type environment of a project? So we have a couple of things that we've made available. So it's possible for module authors to add their own templates into the Nuxt build. And we also enable them to add type templates specifically for augmenting some of the types that Nuxt provides, or even their own.
6. Type Configuration and Development Experience
The module options can be defined and modified at runtime, providing flexibility for developers. Nuxt simplifies type configuration setup and allows for extensive customization. The TS config references generated by Nuxt provide aliases and include/exclude patterns, enabling out-of-the-box type safety for components. Developers can create their own components with type-safe access to props. Type errors are caught, providing a seamless development experience.
They are fully asynchronous, they can be defined at runtime and modified at runtime, based on what the user is doing. So if you're in development building something and you create one file or change the module options, it might change what the module decides to do, this particular type template would automatically be registered in the app context. So it would write this file to disk and then add it to the ts-config references. And it would define that there is another field on your root metadata that you can set in the define page meta helper and you can access in use root.meta if you're interested.
But the point is that it's simple for module authors to do. And in fact, everything else about the type configuration setup is also accessible. So whether that is the specifics of the ts-config that we generate or references and declarations that we actually inject through a custom nuxt.d.ts file, it's all available, which means there's almost no type customization that's impossible to do from a module point of view. So hopefully that gives enough infrastructure for extensibility on the type front. But then there's really the most important bit, which is that we then need to expose the reality, like the fundamental reality of what Nuxt is and how the bundler works to the user in their IDE, developing that, and of course, at the type checking stage as well, that goes without saying.
So I thought I'd just do a little demo of what that might look like. So this is a Nuxt app I've started, it's just very basic. I'm running a dev server in the background, I think. And you'll see, of course, we have this TS config. This is just a cloned fresh project. And this TS config actually references another TS config, this one is generated by Nuxt here. And it has lots of information, so these are the aliases that are available. And we generate some include and exclude patterns as well. And so then in your app, that means you don't really need to configure anything. So out of the box, you get something like, you get types of safety for components that are available to you to be auto-imported. And so in this case, Nuxt.welcome is a component, you get type-safe access to its props. If you create your own component, you would have exactly the same thing there, too. So if I created a MyComponent.view and I gave it, say, some props, I'll use the viewSyntax for defining props at the type level, I would do something like customPropString. And then I could actually go ahead and use this in my app. So I'm going to be able to actually access MyComponent directly. It exists. It's going to throw me a type-error because it actually needs to have customProp provided, and that actually has to be a string. So I actually have to give it a value. And so I have type safety. I can actually click to go to the component. I also have other kinds of things.
7. Type Safety and Environment Variables
By creating a server endpoint, I can access the list of routes and the data that comes back. Type safety can be enforced by specifying the allowed methods. Creating custom layouts and using middleware provides type-safe access to routes. Nuxt consumes environment variables through RuntimeConfig, allowing for specific runtime changes. The use of RuntimeConfig provides type-safe access to environment variables without additional validation.
Oops, I'm playing as TS. That I can actually access in the script. So, for example, if I were to create a server endpoint, like the one I just demoed, then I would be able to do something like this. Poo bar, and that is then going to be directly accessible here. I'll get type safe access to the actual list of routes in my app, and I'll get access to the data that comes back. And in this case, you could access that endpoint with a post method as well. I haven't specified. But if I did restrict it, say something like this can only be accessed by a get method, then actually TypeScript is going to throw me an error here as well and say that it has to be a get method. So there are a lot of ways we can bring type safety this way in the app.
If I were to do something like create a layouts folder and create a custom layout, something like this. And I might also do something. We have a concept of middleware that controls whether or not you can access a particular route or whether you might be redirected somewhere else instead. Both of these are type safe from the user's point of view. So if I were to enable routing in the app, which is done just by creating a pages directory, and then I wanted to use the layout and the middleware in my page, I would actually at this point have type safe access. So I would know that the layout has got to be actually custom is the only option there for me. And if I want to pick a middleware, well, again, I have type safe access. It's got to be auth middleware, which again is a really useful thing because it means that if I later go and rename one of those things, I'm going to get a warning when I or an error when I try and build the app.
There's more. So from a Nuxt perspective, the way that we consume environment variables is through something called RuntimeConfig. We don't want to pass everything in at runtime, we want to be very specific about what is continues to be changeable at runtime. So in this case, I've set a GitHub token. That's going to be accessible... Well, to start with, we are able to generate a little hint, which says that this is actually overridable with this environment variable. So if you create a .env file without value, then that is going to define what it is going to be at runtime. So that's a useful thing maybe for DX for users. If I then go and try and use that in the app, so something like use RuntimeConfig, then I actually get type safe access to the fact that I have GitHub and Token, which is again quite a useful thing to be able to have. And it effectively means that I get type safe access to my environment without needing to add any additional validation. Next does it for me. We also have other things like we have an app config, for example, this is used for things like reactive but build time configuration. So I might say something like, I'll stick a theme.
8. App Config and ts.config
There are different sources for app config, including modules and theme layers. A separate ts.config is used for the server folder to provide a different set of auto imports. This enhances the developer experience by specifying what can be used where. For more information, visit nuxt.com or connect with Nuxt on Twitter, x, or Mastodon.
And there are actually a lot of different sources for app config. You might have a module, which adds something, or you might have a theme layer that you've created. And I'll just try and replicate that by doing something in an app config file and also in this app config file. We use just a basic type to merge that and actually deduplicate and remove undefined values based on the different app configs that are provided.
And so you would also have access to something like that where you would actually be able to access the theme. And maybe I haven't saved it yet. And you would have the same access in your next config as well. So the foo.bar would be able to come through as well. Where's this? So I should be able to have access to that as well.
There's a lot more, so much more that we could have. But one thing I do want to highlight because it's an interesting pattern and we're still working on this. But we have a separate ts.config for our server folder because it's actually a separate environment. So some things are part of the view part of your application. That's most of your Nuxt app. That's the front end bit. So we have things like refs. So this is a reactive value, and it's going to be tracked. It's reactivated, it's going to be tracked, it's going to trigger rerenders. But actually, we don't make this auto import available in the server routes because the server routes are not really running in a view context. So by having this additional ts.config, we're able to tell the IDE that this directory has a different set of things, a different set of auto imports. So we're actually able to give the user a better developer experience because they know what things can be used where. And I think there are a lot more things like that, that we can unpack and make available as time goes on.
Well, there is so much more that I would love to show you. But I'm afraid I don't really have time now, and there's so many good talks yet to come. But if you are interested at all in any of what I've shown you, do check out nuxt.com. There's some documentation there and a Getting Started guide. You can follow Nuxt on Twitter or x, as I think it's starting to be called, or Mastodon. And we have a Discord server that is pretty, pretty active, and I'll definitely see it if you ask a question there. But do get in contact with me if you've got any questions or there's anything I could do to help you get started with Nuxt or if you have got an idea or a suggestion for how to make things better, I would also love to hear from you. That's pretty much it from me, but it's been a real pleasure to be here talking to you.
9. Benefits of Migrating to TypeScript
Auto-complete and IntelliSense in Nuxt
How does auto-complete and IntelliSense work in Nuxt for external APIs? You can provide your own type or API client. The internal API uses an interface that can be extended. You can also provide your own type or extend the interface. Nuxt does not plan to move to JSDoc, but the speaker appreciates the motivation behind it.
So let's go over to the questions that people have for you. I've got a good one to start off with. How does the auto-complete and IntelliSense work in Nuxt for APIs that are not written inside of Nuxt? So when you're talking to an external API, how does that work? So you can provide your own type for that if you want. Or your own API client if you need to. So the way that the internal API works is that we have the path, like forward slash API test is a key of an interface. And so that interface can be extended. You could extend it yourself with other keys. You could extend it, for example, for entire URLs. Although that's not necessarily something we document. And you can also provide your own type. Provide your own type and wrap the internal fetch with with your own type. Or you could just extend the interface and type the result. Yeah. Okay, that makes sense. So you do it like a declare global, you've got the interface inside and you add things to it. Yeah, nice and simple. It would be extending. It's actually an interface export form NitroPack. So you just do declare NitroPack and then interface internal API, and then you would put the key. Super smooth. Yeah, it's nice. It's an interesting idea to do it with a global interface, that might have been a better idea. I mean, I'm sure you found the right solution, definitely.
Tooling Issues and Inspiration
When it comes to tooling issues, it's better to focus on improving the existing tools rather than changing the language. I prefer seeing the source files, especially when working with framework code. In terms of inspiration, I wasn't aware of many other frameworks doing end-to-end type safety when we started. However, TRPC was a great discovery that aligned with our goals. The key is to minimize code generation and let TypeScript handle most of the work. There are no downsides to this approach, as long as we avoid getting too involved in code generation.
I think that's a tooling issue though, rather than something that should lead me to change the language I'm writing the underlying code in. So, I think, I mean, there are other solutions there. So, for example, it's relatively easy in VS code just to go to the top and select the source file rather than the declaration file if you want to see the source. And I think that's also something that's going to get better with future versions of VS code. Like, you can set an option to decide where you want to go. Although, I mean, it's motivating, I guess. I would prefer to see the source files. Yeah, especially when you're working in framework code, too.
Another question here from someone called Matt, who full disclosure is me, what other frameworks and libraries were an inspiration when you were thinking about end-to-end type safety for Nuxt? What do you think, oh, that framework's doing really well. We need to copy it or draw inspiration from it. What were your things that you drew inspiration from? So in honesty, I was not aware of a lot of other frameworks doing this when we started to do it. So about the time that I started tweeting about the initial typed fetch, TRPC Alex reached out and said, hey, I'm doing something, have you come across TRPC? And I had never heard of it and started looking into it, it was amazing, I guess sort of convergent evolution that we're sort of going towards the same thing. And I don't think I'm missing anyone out, but I think a lot of us in the ecosystem, we're just all reaching for the same thing at the same time. Which is, you know, TypeScript is amazing, how can we make it automatic and how can we sort of get that end to end type safety.
There's an interesting one as well. A really good question here. We'll make this last question. Nux is relying on code gen that allows for automatic type inference. Are there any downsides that you've noticed to that approach versus let's say a TRPC approach which is code gen less? So, the main thing I think I just repeat what I said, which is that the code gen should be as little as possible. So, just pointing TypeScript to sort of this is the key and the default export of the file is the result that matches it. So, that TypeScript is really doing as much as possible and that the code gen isn't the hinge like it isn't the main thing. It actually means that it would be possible to do it yourself even. It's not a lot of code that's generated. I think that is the best thing. I don't think there are any downsides from that. The downsides would be if you get really involved in the code gen, which I want to steer clear of. Like a sort of GraphQL code gen approach or something. Well, Daniel, thank you so much for that questions. Thank you for an amazing talk. Everyone, give it around of applause for Daniel and you can go and meet Daniel in the speaker's room on Spatial.Chat as well.