Maintaining widely-used JS libraries is already complicated, and TypeScript adds an additional set of challenges.
Join Redux maintainer Mark Erikson for a look at some of the unique problems TS library maintainers face, and how the Redux team has handled those problems. We'll cover:
- Tradeoffs of different ways to define TS types for a library
- How to target different versions of TS, and considerations for determining the supported version range
- Migrating existing JS libraries to TS
- Differences between writing "app" types and "library" types
- Managing and versioning public types APIs
- Tips and tricks used by types from the Redux libraries
- TS limitations and possible language-level improvements
Lessons from Maintaining TypeScript Libraries
Maintaining widely-used JS libraries is already complicated, and TypeScript adds an additional set of challenges.
AI Generated Video Summary
1. Introduction to Mark Erickson
Hi, I'm Mark Erickson, a Senior Frontend Engineer at Replay, known for answering questions about React and Redux, collecting helpful links, writing blog posts, and being a Redux maintainer.
So why do we even provide types with a library anyway? Types serve several purposes. One is API documentation. Users can look at the types and understand what functions and types exist and how you can use them in your application. Another is user code correctness. They can enforce certain usage patterns that users should have in their actual application source code. Along with that, there's library code correctness. Types help us ensure that the code inside our library behaves as expected, and it's about maintainability, being able to work on the actual code inside the library.
Now, I will say that I think there is a distinct difference between the types that you see inside application code and the types that you see inside library code. Application types tend to be fairly simple. You have API responses, function arguments, state that you're dealing with, component props. Usually, it's not overly complicated, and you don't see a lot of generic types in there. Library types, on the other hand, are much more complicated because they need to handle a lot more flexible use cases. Library types tend to have a much heavier use of TypeScript generics. And sometimes, you might even see type level programming where you're doing inference, conditional logic, and complex transformations of types as well.
3. Managing Types and Versioning
You need to include types in the published package directly, test the package with TypeScript code, use existing typedefs as a starting point, convert files to TypeScript, rename files to preserve Git history, convert tests to TypeScript, export types from the index file, manage versioning for public types, factor types into versioning, divide changes into breaking and non-breaking, target specific TypeScript versions, support older versions, adopt newer syntax and features, test against multiple TypeScript versions.
You also need to make sure that the types are being included in the published package directly. That means adding a types key to your package JSON. And it's a good idea to actually test the publish of the package with some TypeScript application code and make sure that things are working as you expect. And I like using a tool called Yalk to do a local publish of this.
When you're ready to convert the actual code, if there are any existing typedefs, like indefinitely typed, I strongly suggest using those as a starting point. We did that for React Redux. Pick some files, convert them to TypeScript and repeat. And I really recommend having some commits that just rename the files first before you actually begin making changes to them to try to preserve the existing Git history. Also, don't forget to convert all of your tests to TypeScript. This will help catch some of the issues, and we'll look at a couple other examples of this later on. Finally, be sure that you're actually exporting types out of your index file, not just the functions and the data structures in the code.
So how do you manage versioning for public types? This is kind of tricky. TypeScript itself does not use semantic versioning. They just use a decimal increment approach, and that means that any new release of TypeScript could, in fact, actually break currently running code. On top of that, different people can have different TypeScript configurations, and those can lead to differences in behavior. One of the biggest things we see is people turning off the strict or strict null checks flags, and this leads to distinctly different compilation behavior. Really, any change to your types at all, including for bug fixes, could result in user applications failing to compile.
So does that mean that every time I release a bug fix, this is actually some kind of a major version? The Ember team has been working on trying to set up a set of rules for how they plan to handle TypeScript in the Ember ecosystem, and they published an incredibly extensive RFC where they tried to define what Sember means for TypeScript types, not just for Ember, but an approach that any library can use. And what they concluded is that types are APIs. You should factor types into your versioning, but you can divide it into changes that are considered breaking and changes that are considered non-breaking. And they do make some exceptions there for bug fixes and intent. It's possible that they might make a change that would introduce some errors into user code, but it's actually preventing misuse of certain patterns in the user land. But certainly their goal is that if you follow along with the list of supported TypeScript versions, you shouldn't see new errors showing up in the user code. Another related issue is how do you target specific versions of TypeScript. TypeScript comes out with several new versions a year and those can have pretty sizable new features and functionality. So how long do you support older versions of TypeScript? How soon can you adopt newer syntax and features? And how do you approach testing against multiple versions of TypeScript? It's worth noting that the Definitely Typed repository itself tries to support roughly 2 years worth of TypeScript versions. So different libraries approach this in different ways. Ember has defined their set of adoption plans where they've said that the core Ember libraries will use a rolling window where they try to support new versions of TypeScript right away and they drop support for older versions in long-term support versions of Ember. Other Ember libraries might just pick a couple versions. And every time they drop a version it's considered a major release of that library.
4. Supporting Multiple TypeScript Versions
To support multiple TypeScript versions, set up your CI to build and test your application against different versions of TypeScript. Use an older version of TypeScript during development to restrict yourself to the syntax compatible with that version.
The Stately group, on the other hand, has said that we reserve the right to make changes to our TypeScript support as our understanding of TypeScript and its behavior evolves over time. So how can you support multiple TypeScript versions? The biggest thing that I see here is setting up your CI to build your tests and your application and test them against multiple versions of TypeScript at once. This can be done using something like the Matrix support in GitHub Actions. It also might be helpful to use an older version of TypeScript as you're actually developing on the library because this forces you to restrict yourself to what syntax you can actually use as you're working on the code.
5. Handling Multiple TypeScript Versions
TypeScript has built-in support for using multiple copies of type definitions based on the user's TypeScript version. It is recommended to support multiple versions of TypeScript in your main types. The 3DX libraries make an effort to support multiple versions, taking intent into account when making changes to types. The number of supported TypeScript versions depends on the needed features, typically the last four or five versions. Documentation of supported versions could be improved.
TypeScript does have built-in support for using multiple copies of type definitions based on what version of TypeScript the user has in their application. If you define a types versions field, you can point TypeScript to a comparison and specific other types definition files, and it will use those instead if it's an older version of TypeScript. It is also possible to kind of backwards compile some type of definitions to work on older versions of TypeScript.
In practice though, I suggest using types versions as a last ditch fallback. Ideally, your main types should support several versions at once. So how do we handle this with 3DX libraries? We don't actually have a specific defined versioning policy that we've written out. In general, we try to do just a best faith effort to try to support several versions of TypeScript at once. And we take intent into account when we make changes to types. Like, if I make a change that technically introduces a new red squigglies, but my intent was that I'm fixing a bug, then I would release it as a patch version rather than a major version. There have been a couple times where we put out changes that were technically breaking, but it was only after I did a search of public code usage and I saw that no one is really actually using this type in practice.
We also don't have a strictly defined definition for how many versions of TypeScript we support. In practice, it tends to be the last four or five versions, so about a year's worth of TypeScript releases. It really depends on what features we need. TypeScript 4.1 in particular was really big because it introduced a bunch of new string features. We rely on that for things like the RTK query hooks APIs. We also frankly don't do a good job of documenting what versions of TypeScript we currently support, and that's a thing we could do better at. Although I've at least tried to list what when we're dropping support for a version in release notes.
6. Debugging Types and Designing APIs
Typescript types can have bugs, so users need to provide reproductions of issues. Different configurations and setups can cause variations in type behavior, especially when strict or null-checked flags are turned off. Debugging types is not easy, but you can tweak VS Code settings and use the Any.compute type in the TS Tool Build library. Testing types in your library is crucial, and utilities in Redux libs can help with this. TypeScript pushes for simpler API designs, as seen in React Redux's Hooks API.
So since types are code, that means they have bugs, and users will report lots and lots of issues with TypeScript types. And that means that we need users to provide reproductions of those issues, and this is especially true because different users can have different configurations and different setup.
So at a minimum, you need to know what version of TypeScript you're using and how they have it configured, and this really means users need to supply sandboxes or GitHub repos or TypeScript playground links that show this kind of an error happening. One of the biggest issues we see is cases where people have turned off the strict or null-checked flags, and this really does introduce differences in how our types behave. So at this point, we flat out tell users, if you set that to false, that's your problem, not ours.
Unfortunately, there really isn't a good way to debug types very easily. You can't slap a break point in the middle of a type definition and stop and see what TypeScript is currently calculating. Also, if you're hovering over variables in VS Code, a lot of times it limits the output size by default. There's a couple ways you can tweak this. You can change the no-error truncation flag in your TS config, or you can actually dive down in the TypeScript code and alter the hardcoded value for how much output it shows in the hover in VS Code. One suggestion is if you have some complex types, recreate those step-by-step and assign them to separate types variables and just try and see what it's doing each step of the way. There's also a type in the TS Tool Build library called Any.compute which can do some recursive expansion of types.
It's very important that you try to test the types in your library and we have type test files in the Redux libs. And this is really just TypeScript code that needs to compile cleanly and has some assertions in there that says I expect that this variable is a certain type. You can write these as plain test files that get run through TSC. You can have them as skipped tests in a test suite. But the idea is I want to know does this code compile cleanly? And we've created a lot of utilities in the Redux libs for things like expecting certain types to exist.
7. Advanced Redux Type Tricks
Our types can be complicated, but they make users' lives easier. Providing pre-typed types can be helpful. Redux libraries use conditional types and x extends y comparisons. Nested ternaries can be hard to read but are sometimes necessary. The Redux Toolkit has types for different scenarios, including action creators and createAsyncThunk. In createAsyncThunk, defaults are provided for optional types. Reselect has a complex type that handles variadic inputs. Optional or named generics would help maintainers going forward.
That means that our types are more complicated as a result, but it makes our users' lives easier. Now sometimes you can't actually know the final types that a user is going to need to provide in the core function or type definitions yourself. So sometimes it's necessary to provide a type that a user can import, and override, and pass in values like the final Redux state in their application. So having these pre-typed types that they can use with their code can be very helpful.
So let's look at a few different tips and tricks from the actual various Redux libraries. You'll see a lot of conditional types in there, and conditional types are basically types-level comparisons and ternary operators. And you'll see a lot of x extends y in there, and this is both a like a comparison and a like does this type match this criteria? You can, just like real code, you can nest these ternary statements. So a good example of this is the thunk middleware for type from Redux Toolkit's Configure Store, where we're trying to figure out what type should be included for the thunk middleware in the store setup based on the options the user has provided. So if they said that the thunk middleware should be turned off, then we don't want to include a type for the middleware at all. If they included an extra argument value, we need to use that. Otherwise, it falls back to the default type. Or the payload action type, where we know that we want to start with a payload field and a type field, but there could also be a meta field or an error field, depending on how the action creator was defined.
Now, it is true that ternaries can be hard to read. I've never been a fan of nested ternaries myself. And unfortunately, in TypeScript, sometimes you got to do what you got to do. And yeah, this can get a little bit out of hand. This type represents all the possible ways that a Redux Toolkit action creator could be defined with all the various optional and fields that can be passed through. Okay. Yeah. Sometimes this really gets ridiculous. Like this type that represents a createAsyncThunk action creator. If you think this is really hard to read, yeah, I completely agree. Now, one trick we do use in createAsyncThunk is providing some defaults for several optional types and giving users a way to override these. Ideally, all they do is provide a type for the input argument and the return type. Everything is inferred from there. If you need to override something like the state value for use with getState, then you can conditionally override just that one and leave the other fields like dispatch and extra alone. Over in reselect, we have an incredibly complex type that does a bunch of types level mapping and transposition and extracting. It took me weeks to come up with this type and I had to get a lot of assistance from other people, but this saved us over 3000 lines of multiply defined types to handle 1 to 12 variadic inputs. This required a lot of tricks like using default values for generics to basically precalculate some variables or mapping over tuple and ensuring that we only use numeric fields. So what kinds of things would help maintainers going forward? By far the biggest thing is optional or named generics.
8. Maintaining a TypeScript Library
Specifying one or two generics instead of all of them at once would make our lives easier. Better ways to debug or visualize types and specify error messages for types would be helpful. The TypeScript compiler needs to output better error messages. Members of the TypeScript team have proposed ways to improve this.
This would make our lives so much easier if we could just specify one or two generics instead of all of them at once. Also, better ways to debug or visualize types as they're flowing through the type system. It might also be helpful to actually specify error messages for types so that when users have specific bad input then we can define what the error messages should be. Having some built-in types comparisons would be helpful, and frankly the TypeScript compiler needs to output better error messages. And there's been some proposals from members of the TypeScript team on ways that they could do this. So hopefully this gives you some ideas of what it's like to maintain a TypeScript library and some of the tips and tricks that we deal with to handle actual real-world examples.
If you've got any questions on this, please feel free to talk to us in the chat afterwards or drop by the Redux channels in the Reactiflix Discord. Thank you, and have a good day.
9. Discussion on Poll Results
The poll results show that a significant number of respondents at the TypeScript conference use TypeScript between 51 and 90 percent, while 30 percent use it 100 percent of the time. Surprisingly, 14 percent of respondents use TypeScript 0 percent, indicating a desire to learn. These results reflect the transitional nature of applications, with many developers incrementally migrating their code bases. As someone who has experience with this, I find the results to be expected and understandable.
Hi Mark, welcome. Let's discuss the poll results. So basically, 50 to 90 percent, 32 percent, use TypeScript between 51 and 90 percent. Then 30 percent of the respondents use 100 percent TypeScript all the time, 24 percent just a little bit between 1 and 50 percent. And I'm very surprised to find out that 14 percent of everybody that responds uses TypeScript 0%, which is quite interesting because it's a TypeScript conference. I would think that all of us will have some usage and move a little bit. But no, there's 14 percent which has 0. Maybe they just want to learn. What do you think about these poll results in general? I think that makes sense. I mean, given that it is a TypeScript conference, you would expect a lot of people to be using a good amount of actual TypeScript code. But I think this does speak to the very transitional nature of our applications. Most of us are not in a position where we're getting to write a brand new all TypeScript, all the time code base from scratch. Many of us are dealing with applications that have, in a lot of cases, been around for years. And so you've got, you know, people are trying to migrate their code bases incrementally. I have done a significant amount of that over the course of my career. So I'm very well acquainted with that idea. Cool. Thank you.
10. Upgrading TypeScript and Verifying Functionality
If upgrading the version of TypeScript is a non-library project, you should be able to bump TypeScript and possibly restart VS code to ensure the language server is parsing things correctly. It's also important to rerun a build step and compile the entire project to verify its functionality.
So we have a couple of questions for you. Anil Polt, he's asking if I'm creating the version of TypeScript as a non-library project, would it be easy as simply upgrading and looking for a red script that might show up the language server is working right? Sorry, run that by me one more time. Yes, of course. If upgrading the version of TypeScript is a non-library project, would it be easy to simply upgrading and looking for a new red script that might show up if the language service is working right? Let's see. I think generally for the most part, you should be able to bump TypeScript. I found that as a general rule in both application development and library development, there's definitely times when I have to go in and use the VS code command to either restart the TypeScript server or actually just reload the entire window, it's the age-old story of restart it and see what happens and see if it works better in order in order to make sure that TypeScript is actually parsing things correctly especially if you're doing something like upgrading the TypeScript version or sometimes modifying other library versions that you have in the app. But generally like bumping those things and possibly restarting it should be enough to actually have VS code and the language server start doing some reprocessing. Now as a general observation I've also seen that just because like VS code is limited generally to just files you have open and there's a lot of files you don't actually have open. So looking at things in the editor helps but like you're also going to want to actually read like rerun a build step and have it try and compile an entire project to see what like actually make sure that it's working the way you think it should be.