Building educational TypeScript tooling
9/28/2022 · 10 min read
TypeScript is still growing. In the past 5 years, TypeScript has gone from 1.7 million downloads a week to 34.6 million. It was one of the top most-loved and most-wanted languages in the Stack Overflow Developer Survey 2022, and CircleCI found it had overtaken JavaScript to be the #1 most-used language on their platform in their 2022 State of Software Delivery report.
There are many reasons to explore as to why TypeScript has been growing so consistently, but today we're going to look at how to provide a developer experience that guides users through discovering, understanding, and using your code without them ever leaving their editor.
Focus on the essentials
When developing any code, it's easy to get stuck in the detail and not realise how much a new user might have to understand just to get started. Providing organised source code, guides, and external documentation are all fantastic supporting resources, but the perfect experience is one where they can learn by using.
Let's pin down two key points we'll need to achieve this.
Discoverability. Our developer should be able to effortlessly discover the capabilities of the code and how to use it without referring to external sources or having to read it directly.
Feedback. Our developer should be able to understand input/output data and errors such that they can remedy them on their own.
Keep these two points in mind as we start looking at some examples.
A Dumb Protector
Without much effort, TypeScript affords us a tonne of protection, inferring types to protect against runtime errors and ease discoverability. We can go even further, though; TypeScript has so much it can offer if you know how to bend it.
Let's dive into an example to show how we can adapt TypeScript to protect our code far beyond the usual inference. We'll start simple - I want to create an object containing a list of hobbies and whether or not they are my hobbies.
const myHobbies = {dancing: false,typescript: true,};const likeDancing = myHobbies.dancing;// ^?
We can see here I've created a simple object and then assigned one of its values to the likeDancing
const. TypeScript has correctly inferred that myHobbies.dancing
is a boolean
, but we can do better.
Using a const assertion, we can ensure that the literal type false
isn't widened to become boolean
, meaning likeDancing
now knows at compile time that it's going to be false
.
const myHobbies = {dancing: false,typescript: true,} as const;const likeDancing = myHobbies.dancing;// ^?
Nice! I'm worried, though, that if I have multiple objects tracking hobbies, I might forget to update all of them when I add a new hobby. Let's look at providing our hobbies with a type to adhere to, ensuring we're always accounting for every hobby available.
We can do this using a Record<>
type, where we specify the keys and values we want in our object.
type Hobby = "dancing" | "romancing" | "typescript";// The compiler will make sure we account for every hobby!const myHobbies: Record<Hobby, boolean> = {// Error: Property 'romancing' is missing in type '{ readonly dancing: false; readonly typescript: true; }' but required in type 'Record<Hobby, boolean>'.dancing: false,typescript: true,} as const;
As you can see above, we declare a union type called Hobby
, then use it when declaring myHobbies
as Record<Hobby, boolean>
. The TypeScript compiler can see that we're not specifying all listed hobbies and will fail to compile, protecting us from missing any options.
This is a huge boon - if I have multiple objects around my codebase using this pattern, updating the Hobby
union will show me every other place I need to account for the new hobby.
We still have a problem, though. Can you spot it? You can hover over the types to see what's what.
type Hobby = "dancing" | "romancing" | "typescript";const myHobbies: Record<Hobby, boolean> = {dancing: false,romancing: false,typescript: true,} as const;const likeDancing = myHobbies.dancing;
likeDancing
is once again a boolean
. Our typed object now overrides TypeScript's inference and widens the understood types back to boolean
instead of true
or false
.
Removing the Record<Hobby, boolean>
type would mean we could add any key to the object, which isn't ideal; we want to enforce the correct keys and values.
If we wanted to go all out in this example, we could create a Hobby Factory™ which would enforce the correct type as input, then infer the specific output types afterwards.
type Hobby = "dancing" | "romancing" | "typescript";/*** Enforce input and infer the literal output*/const createHobbies = <T extends Record<Hobby, boolean>>(hobbies: T): T =>hobbies;const myHobbies = createHobbies({dancing: false,romancing: false,typescript: true,});const likeDancing = myHobbies.dancing;// ^?
This feels a bit overkill, but we achieved some pretty cool things for when anyone else comes to use our code:
- We have a safe
createHobbies
function which will enforce users are accounting for every hobby - We infer the literal types of our output object, so users can know in their IDE what the values are going to be
What we've seen here is that - even in this small example - TypeScript can provide some really cool inference above and beyond its basic form, but you have to give it a push.
Building a guided experience
Now we know that the TypeScript compiler can perform really cool inference when prompted, let's look at how that might translate to writing a new function in your codebase to provide your developers with a guided, first-class, type-safe, and enjoyable experience.
We want to create a function which mimics Node's EventEmitter
. To ensure all of the supported events are discoverable, we want a readable function definition to ensure we can write something like on(event, doFn)
.
To start with, we could write this with simple types.
const on = (event: string, fn: (...args: any[]) => any) => {// ...};on("event", () => console.log("Something happened"))
Great. That works, and it's definitely TypeScript, but it's not the best. For a user, I still have to know what events I can listen to, meaning my code likely has to throw a runtime error if I listen for one that doesn't exist.
Let's add a simple union type to make this much easier to write.
const on = (event: "server.start" | "server.stop",fn: (...args: any[]) => any) => {// ...// We're protected at compile time, and we get autocomplete!};on("event", () => console.log("Something happened"));// Error: Argument of type '"event"' is not assignable to parameter of type '"server.start" | "server.stop"'.
Awesome! Not only are we now protected from making mistakes, but we also get to discover events we can listen to via autocomplete in our IDE.
In our case, we also want the event to send specific data, which changes based on the event.
server.start
sends asuccess
booleanserver.stop
sends astoppedAt
timestamp
Again, we could implement this with some basic types:
// @noErrorsconst on = (event: "server.start" | "server.stop",fn: (arg: boolean | Date) => any) => {// ...};on("server.start", (success) => {// ^?
But now the user has no idea what argument they have! Is it a success boolean? Is it a timestamp? No! It's your user opening a browser tab to go check the docs.
We can do better - let's use some clever type inference to find the event that's being passed and type the function correctly:
// @noErrorsinterface Args {"server.start": boolean;"server.stop": Date;}const on = <Event extends keyof Args>(event: Event,fn: (arg: Args[Event]) => any) => {// ...};on("server.start", (success) => {// ^?
Much better. We've made on
a generic function that will infer its generic argument from the input. The compiler understands that "server.start"
is the event, so it can appropriately infer what the input of our function will be.
The user is still manually naming their success
argument, though. Even though they can see it's a boolean, it might not be clear what it's representing. Finally, let's make our incoming arguments an object so we can name them explicitly for the user.
// @noErrorsinterface Args {"server.start": { success: boolean };"server.stop": { stoppedAt: Date };}const on = <Event extends keyof Args>(event: Event,fn: (arg: Args[Event]) => any) => {// ...};on("server.start", ({ success }) => {// ^?
Fantastic! By defining an object and encouraging destructing, it's now super easy for users to see the data they're dealing with. The compiler holds their hand to push them back on track should they stray from our guided path, and our explicit naming ensures the user can discover what they have access to without leaving their editor.
Remember - these are just types - the functionality is exactly the same, but the experience for the user here is vastly different. Let's look at two examples - one with looser typing and our final example here.
Our user here has no idea what to type. The best case scenario is that there's some helpful JSDoc comments for the function to give them some idea of where to go, but anyone writing this code (or reading it) will likely have to head to your docs to understand what to do.
In addition, the user runs a considerable risk of runtime errors because the compiler can't tell what the data should be.
Worlds apart! With our better types, our user is guided through the process without having to read anything! Autocomplete kicks in and shows them exactly what they can do at every step.
This code produces fewer errors and is faster to write, more enjoyable to write, and easier to read and parse further down the line.
Conclusion
Many practices contribute to making a piece of code easy to use, but baking the discovery process into the tooling itself is often overlooked.
Ecosystems such as C# with tools like ReSharper have been benefitting from this in-editor luxury for decades, with tooling that teaches devs about new language features and styles without them needing to keep up with the latest release notes.
We can bring some of that joy to TypeScript, even if we have to make a little extra effort to give the compiler the confidence to do so.
These considerations and techniques are used throughout our Inngest SDK, allowing you to build, test, and deploy scheduled tasks and background functions triggered by events, all without worrying about infrastructure, queues, or stateful services. Go check it out ➡️.
Help shape the future of Inngest
Ask questions, give feedback, and share feature requests
Join our Discord!