1. Introduction to TypeScript for Library Authors
TypeScript for library authors offers benefits for both internal and external use. Internally, it helps improve code quality, cover edge cases, and facilitate refactoring. Externally, it provides users with an accurate understanding of how libraries work, offering a true-to-life representation of their functionality. TypeScript allows users to gain insight into libraries and understand the expected behavior of function calls.
Hello. It's a pleasure to be here today, talking about TypeScript for library authors. It's dear to my heart because I'm self interested and love using libraries with great developer experience, and I'm sure you do as well.
My name is Daniel Rowe. I'm based in the northeast of the UK, and I'm on the Nuxt core team, and I maintain a few other open source packages as well. I should say in advance, this is my perspective as we dive into TypeScript for library authors, you may have other ideas. You may have better approaches, and I'd love to hear from you if that's the case. Please do contact me, either by email or Twitter is a great way to make contact. Please. I'd love to connect later.
So, to start with, the question I'd like to pose is what's the point of TypeScript for library authors, I guess. And obviously, even if you weren't exposing your types outside of your own project, I think there would still be benefit. I certainly would find that there would be myself. I think TypeScript helps improve the code that we produce. It helps us cover edge cases. It helps us see the possible alternatives and outcomes of what we're creating. And it helps with things like refactoring, making sure we haven't left something out. Lots of huge benefits for using TypeScript internally.
But I think looking at the question of why we use TypeScript for external benefit, the key reason is truth. And I think there are a couple of different implications for this that I hope to go through today. But I think it all comes back to truth. We're allowing our users a window into how our libraries work. That window is accurate. It needs to be accurate. We're offering them an alternative to looking at our source code or reading our documentation. Something that is actually a true-to-life representation of what is happening. And that I think is one of the things that makes TypeScript so amazing, when you're using it as you consume some other library. Because you're getting insight through it. Your IDE popup is telling you something about how it works under the hood. It's telling you something about what you can expect to come back from that function call.
2. Documentation and Examples in Code
Types are part of your documentation. API documentation and examples should be in your code. Users get up-to-date information when using your library. No need for a separate website. You can add links, examples, default values, and version information. Mature standards like jsdoc provide a great way to see documentation in your IDE. You can even host it using tools like jsdocs.io or unpiped.
The possible options you can pass to it. That kind of thing. It's all about truth for me. And the first implication is documentation. That's about truth as well, isn't it? So types are part of your documentation or maybe even your documentation. Certainly, types and TS doc, JS doc, I'll use the terms interchangeably even though they're not exactly. But they can provide your library documentation.
So if you think of adding comments immediately before every function, every interface, every constant that you expose from your library, you can actually accurately describe that there. My hot take for today is all of your API documentation and examples should be in your code. It means that when your users are actually using your library, they get up to date, accurate information about the version that they are consuming at that moment in time. You don't have to have a fancy website with a drop down allowing users to select major versions, if your documentation is actually in the code itself. Their editor will surface the correct version for them. You can add links. You can add examples. You have the full power of markdown at your disposal. You can add default values. You can add the version something was introduced to the project at. Anything else that you might think or consider, jsdoc, they're really mature standards. And there's nothing quite that beats being able to see the documentation for what you're using in your IDE rather than having to go somewhere else or perform a quick search. You can even host it that way. Check out jsdocs.io. You can actually type any package in, and they'll render up documentation for you based on the declaration files for that project. You might even consider something like unpiped which is a package in the onjs.github organization which enables you to have a typed configuration schema. It might not be relevant for your project, we use it in Nuxt. We are able to type our configuration schema, and then that can be exported to json or to code. We can do things with it, such as generate documentation programmatically. That is what we use to ensure our Nuxt config documentation is accurate and updated on the website. It is always based on the actual code and the JSTOC in that code file.
Here is just a little example picked from a library I maintain. It is not complex, it is not difficult as an author to do this.
3. Using TypeScript for Documentation and Testing
TypeScript allows you to add only the required values, providing a true-to-life representation of functionality. Testing your types alongside unit tests ensures accurate typing. Tools like Unbuild help with testing built files, ensuring accuracy in development. Versioning types is essential, as changes can break user code or compilation.
You don't need to type everything, TypeScript has got your back as far as that's concerned. You can add only the value that is required. An example of how something might be used, a further explanation of what a particular parameter might be, then obviously some kind of overall statement, and just look at how that comes up. Isn't it beautiful? Doesn't it just surface something that might be really useful as a user of that code?
So there we go, types as documentation. But I think another point from this concept of truth is that we're not just exposing our runtime code, we're also exposing our types as a library. That is our code as well, which means implication number one is we have to test them. I particularly like expect type as a library, and that's what you're seeing here, but test as well is a great tool that I've used also in other projects. So you can actually just test your types alongside your normal unit tests with something like expect type. So you can narrow down on a particular return value and actually expect that that value is going to be the type that you want it to be. In order to make this work, you just need to run ESC and noemmit on your test files as well as running them with your test runner, vtest or just or whatever it is. And don't forget the fact that TypeScript itself is actually able to help us too. Particularly useful is tsexpect error which will throw an error if there is not an error in the following line. It's a great example of saying this should this to generate the red squiggly line when my users do something like this because it shouldn't be allowed. So between expect typeof, which comes with a huge array of helper functions that can be chained off of it and tsexpect error, you can pretty accurately type your code, type interface for your users. I think that's absolutely essential for a library author. I would also recommend as a follow up for that testing your built files because sometimes you can have a situation where the source files are fine from a typing point of view. It's not until things are built that you get some kind of problem. And helping with that are tools like Unbuild and others that enable you to have a stub mode so your dist files in development just effectively point back to your source files. Unbuild writes stubs that work in node so you can actually use it in development. But it also just means that you get the accurate built files when you do actually build your project. As long as in your tests you are importing from your built library. So just type your full project name and then make sure you have tsconfig entry or you've set up an alias in your test runner and that way you can always be testing your built files in your type tests.
Another implication if types are code, version them. Adhere to semver if you can. For example, you might release an enhancement in your project. Normally an enhancement wouldn't trigger that's not a breaking change. But if it changes your type such that something breaks for your users or whether compilation will break in that project, then it is a breaking change. An example would be, I have to put my hand up here, I put a PRN to definitely types to change the type of end and a few other stream-related types and it seems that there was a small discrepancy between reality and the node types. Res end returns this, not void, so we wanted to correct that. But just looking at the implications of that change it was huge.
4. Managing Changes and Exposing Types
Making changes to types requires careful consideration and proper versioning. Tools like API extractor can help maintain type contracts. Deprecating types and adding function overloads can minimize the impact on library maintainers. Exposing only intended types is crucial. Using build tools like rollup-plugin-dts or dts-bundle-generator can control access to types. Types serve as documentation, code, and truth, and should not be used to deceive ourselves or users.
Suddenly streams that were being passed around could not be passed to functions that were expecting the old type of stream. So just making that change was a breaking change for lots and lots of people. It still probably had to be made, but you have to be very careful about it. And it takes a lot of effort to make sure that you version your types properly.
I consider a tool like API extractor. It's incredibly full featured. It is a little bit complex to configure, but it can help with ensuring that your type contracts don't change, that you're able to adhere to when you're releasing new changes to types. And obviously, there are things you can do when you are making changes to ensure that they're not full on breaks, but things are a little bit gentler. So, you can, for example, just deprecate a type if you're renaming it. So, users do have some time to change their usage. And you can do something similar, of with functions, rather than just change the type of the function. Again, this might not be necessary. But in some cases, it could be. You can add an overload, so that the function continues to have the previous signature, but it also has a new signature, and it handles both. So, there are some things you can do, I think, to reduce the impact on you as a library maintainer from having to release new minor or major versions when you're changing your types.
It's also worth saying that if your types are your code, if you're thinking of them as your API and a contract with your users, that it's really important that you're only exposing the types you intend to. There'll be lots of internal types, types that you're using for utility purposes. There might be utility code that is just internal. By default, TypeScript does something which I do find unaccountable, and that is it obviously renders the full structure of your project out in declaration files. So, users can, by adding the path to your source files, access the types and the declarations for every single function and type that is exported in any one of your source files, even if it's not exported from your final entry point and bundle. So, using something like rollup-plugin-dts or dts-bundle-generator means you can actually roll that up so it won't be accessible from your final index.dts. That's a hugely useful thing in conjunction with everything else I've been talking about in terms of testing and versioning your types. There are plenty of build tools that will do this for you already, including unbuild, which I mentioned earlier.
And finally, it's not just types as documentation or types as code, but coming to the real point of it, types as truth. Forgive the philosophical or epistemological point. I think this is what types are all about. They're about exposing the reality of the project, of your library, of how it works and what it does. And the corollary, is that we have to avoid the opportunity for lying to ourselves and our users. Because we do this all the time, and I am very prone to this, it's really easy to put an as or to type something as any. It's very possible to turn off strict null checks and just enjoy the fact that you're not having to...
5. Strictness and Deep Integration of Types
Library authors should be strict and ensure reliable types for users. TypeScript's inference can be used to determine return types, but testing is necessary to ensure accuracy. Moving beyond superficial types to those that deeply integrate with the library improves usability. For example, using string literals instead of generic strings allows for more specific values. An example from the OhMyFetch library demonstrates how response types can be configured based on user preferences.
You're just assuming that it will be set. But I think the discipline for library authors is we have to be as strict as possible, so that what we are generating and declaring to our users is reliable as far as they're concerned. They shouldn't have to test it for us. We should have fully tested it so that our types are accurate.
As much as possible, I like the paradigm called the fountain of truth, where you effectively have the source of truth coming from where something is... from the actual code that processes that is run in a function. Rather than having a function and manually declaring the return type. I know people have different points of view. I much prefer to allow TypeScript to infer that return type. But then to test it, as I've been saying. To ensure what we are getting doesn't change unexpectedly, doesn't become any, doesn't have an unexpected void appearing. But rather than typing it manually, or even worse, using as to talk about the return type, we just ensure that what TypeScript is inferring is what we're intending to be parsing out. That means we don't unexpectedly narrow a type, for example. Or we don't unexpectedly typecast something when we weren't entirely intending to do that.
And then, I think, probably the most significant paradigm shift as a library author is to move from what you might call a superficial use of types to types that actually get under the skin of your library. So, not just, say, describing something. You might have a parameter that's a string. Describing it as a string might not actually be that helpful. It might be accurate, but not that helpful. Moving from a string to a string literal is already worlds better because you're saying these specific values can be passed into my library. That enables you to actually do something that's much more useful for your end user.
So, take this bit of code as an example. So, this is taken and very slightly simplified from a library called OhMyFetch. It's a convenient layer over the fetch API. One of the things that it allows you to do is pass as an option what kind of response you'd like to receive. Do you want to get a JSON response? Well, that's the default. But you might want access to an array buffer of it or a blob or just the string that's coming back. And we want to be able to configure that. But obviously, that's going to change the type of the response. If you're saying you want an array buffer, the type you get back should be an array buffer.
6. Implementing Customization with a Map
Using a map in TypeScript instead of a chain of extends allows for simpler implementation and customization. The mapped type magic enables the return of the actual type based on the string passed in. This approach provides a nice default set of fully typed responses, ensuring accurate matching of the expected return types.
It shouldn't be anything else. It shouldn't allow you to override that type with some custom type. And we've just implemented this quite simply with a map, which is often a nice pattern in TypeScript. Rather than a chain of extends, you can actually just have a map and write a single extends and pass back what you want.
Extends, by the way, is one of the most powerful tools for this kind of customization that we're talking about here. And this particular code, at first, defines those utilities for us. So, here's the map between the string blob text array buffer and the actual type that's going to come back. Then we have, we allow JSON, which is a bit of a wild card in this particular case, because it can really be anything. We want the user to be able to provide an override that will match with actually coming back. And then we have this mapped type magic, which allows us to return back if the user passes in, I guess we are the consumers of it. We pass in text or array buffer, it's going to return back the actual type that matches that string. And so, then we're able to put everything together in the interface for fetch there, which is that whatever those options are, if response type is text, we get a string promise back. If it's an array buffer, we get an array buffer promise back. And if it's JSON, then we either return unknown or we allow the user to override that. And so, we get a nice default set of responses that are fully typed. That's the kind of thing I mean about getting under the skin of the library. And I'm sure that could be improved so much. Put a PRN if you like. But that kind of move of saying, I don't just want to return the type array buffer or blob or string or unknown. I actually want to make sure that what I'm returning is very accurately, as much as I possibly can, matching the actual thing that will come back given that set of inputs to that function.
7. Tips for Achieving Response Specificity
To return a response specific to the user's input, type inference and a no-op helper can be used. By extending the options interface, more complex functionality can be achieved. Normalizing user input and making it accessible for type purposes is crucial.
So, some tips, I think, for achieving that. So, one, it's a really common pattern now, and I think it's needed. In order to access all the information that you might need to return a response that is that specific to what the user is giving you, it's often necessary to use type inference, which is really usable through functions. So, a no-op function that says, well, the user's passing something, and I don't know exactly what it is, but it should match the options paradigm. But by not just saying it is this options interface, but that it extends it, we can then do a lot cooler things with it down the line. Such as say, if this option is this value, we're going to return this. Or if that option is this value, then this other option needs to match it in this kind of way. So, you may need to make use of that kind of no-op helper and have a defined config or defined options or defined interface, defined schema. Whatever it is that you're producing, having this kind of intermediate step where the user sort of provides some kind of input and you normalize it, but make it accessible for yourself for type purposes, is often really, really crucial. Really key.
8. Leveraging Types for Library Benefits
Types can do all the work for you, guiding users to type only what matches the source object. By moving work from runtime to typing, you can generate code based on user access. In Nuxt, components and plugins are typed to match user reality. Automatic type detection allows for injected values in templates. TypeScript typing can reveal specific function return values. Typed API routes enable event handler definition in the server side.
In some cases, it's worth flagging this. Types can actually do all your work for you. This can actually be where your actual library's benefit comes from. So, for example, you have an object passed in. TypeScript is able to you're able to write types that convey the behavior you want. And then you can have something like a proxy do the actual work.
So, maybe your mapping values from the object to something else and using a nested dot notation. You can actually enable the type to be the guide for the user. So, they can only type the kinds of things that match the source object. And then in your code, you have a proxy that just looks at what values the user actually accesses. What properties of the object that they access. And then you're actually able to generate the code and do what they intend to do that way. That's quite fun, I think, when you move the work from the runtime into the typing there.
Here's an example of what we've done in Nuxt. So, in Nuxt, we've tried as much as possible for the typing to match the reality for the user. So, we have the concept of components that are accessible anywhere throughout the app. So, my component should be accessible here. And indeed, it is. We have the type here generated automatically, so the user can actually jump straight to it with the Vue plugin that we recommend. Plugins themselves can inject values into the Nuxt app instance itself and the Vue instance so they can be accessed in the template. And again, we support automatic type detection for that. So, the user can say, I'm injecting Fu, and in the template, we have it available for the user as a property that's been injected.
TypeScript is a little cleverer when you're typing a function. You can actually see the specific value that's coming back at the end, it should. It should be world. Which is, I think, pretty cool. We have typed API routes as well. When you're actually defining an event handler in the server side of your app. This is just a pretty simple example with a string. But you can have something that's much more complex.
9. TypeScript and Nuxt Integration
We know the return value when fetching an endpoint. Composables are auto imported throughout the app, providing help and support for the user. Nuxt writes type declarations in the background and exposes them to the editor for as-you-type support. Feel free to reach out for more information on Nuxt integration and TypeScript tools.
And actually, we are then able to say, well, we actually know what the return value is going to be. So, when you fetch that endpoint, we know that its response is going to be that simple string. Lots of other examples.
Composables might be a useful one. We have a concept of auto imported composables everywhere throughout your application. So, again, we make that type available globally throughout the app. So, if the user actually just types use me anywhere, they're actually going to get the response of the actual composable. So, again, as much as possible, what we're trying to do is give the user help and support for the actual environment that they are interacting with. In this case, their environment, nuxt, is going to auto import these things and make them available to them anywhere. So, the user shouldn't have to do anything extra for that.
And if you're interested to know how nuxt works, it's basically writing type declarations in the background and exposing them to the editor so that the editor can provide the right kind of as you type support. Which I think is pretty cool. I'd love to answer any questions you might have about that later. Well, if you're interested in any of what I've been talking about, whether it's the nuxt integration, or some of the tools that we have at our disposal in TypeScript to make users have a great experience using our libraries, do feel free to write to me, follow me on Twitter, and particularly if you're interested in nuxt, check out the docs, see some of the things we've done with types. I'd love to talk you through that. Follow us on Twitter or join Discord and feel free to ping me a direct message with any questions you might have.
Comments