03 November 2021
It's not JavaScript's ugly cousin. See how Typescript improves Developer Experience
Developer experience is a straightforward concept of making the day-to-day of software developers better and easier. I’m not going to explain it in detail as our CTO Marek Gajda already did a fantastic job explaining why should we care about Developer Experience in his text. In this article, I’ll go even deeper into technicalities and focus on how a programming language like TypeScript improves Developer Experience.
JavaScript and Developer Experience
JavaScript is a powerful programming language, yet you can easily start the adventure with it. For instance, every computer these days has a web browser with built-in developer tools that allow you to experiment with the JavaScript engine right away. What’s more, the instant (visible) feedback that you get, may encourage you to go deeper.
There is one problem though, apps these days are more complex as users demand from them more than ever. JavaScript is no longer just an ornament on your web page; you build pretty advanced applications with it. This also means that these apps can have tons of possible states, hard to be fully covered by tests. And that’s where JavaScript’s dynamic nature breaks the developer experience because instant feedback that the developer received is no longer the same. Some runtime errors appear only in specific app state combinations.
Why does that happen? JavaScript is a very permissive language. As soon as the JS code syntax is right, it will run. However, that kind of “stress-free upbringing” won’t protect developers from writing code that fails at the runtime. What’s more, the more lines of code you have, the bigger the chance that the code will trigger runtime errors. In other words, when you look at the capabilities of the JS, there are more ways to write code that contains runtime errors than the code that doesn’t.
This clearly breaks the JS developer experience in the long run – it’s easier to create bugs than write code that deals with all of the problematic cases gracefully.
The Elm approach
Elm is a niche programming language that is domain-specific. Namely, it targets the frontend domain, where JS has been the primary option for decades.
Elm implements the functional programming paradigm. Over the years developers figured out that things like immutability (a common feature in the functional programming world) make our code more maintainable in the long run.
Why even bother with such a niche programming language like Elm?
If you ask Elm programmers what’s their developer experience in that language, most of them will assure you that it’s much better than it is in JavaScript. So how does Elm achieve that? If you look at its capabilities, they are much more restricted:
There is a pretty popular phrase across the Elm community: “if it compiles, it works”.
Elm compiler doesn’t let you write code that fails at runtime. One of the features that make Elm stand out is its developer-friendly compiler feedback.
You can probably see that Elm compiler is NOT yelling at the developer that they did something wrong. Elm lets you figure this out for yourself. Instead, it helps by providing as much context as it can and hints at how the problem could be solved. In other words, the Elm compiler works as a dev’s assistant.
How does Elm achieve great DX on a technical level?
The functional programming paradigm is the only way to write apps in Elm. But it’s worth mentioning an additional benefit – Elm is also a statically-typed programming language.
I wouldn’t like to start a flamewar in terms of what’s better, dynamic or static typing. Both of these worlds have pros and cons. Having said that, I believe that statically typed languages slow down software developers, for a good reason:
Why TypeScript?
What’s the deal with “Typescript improved Developer Experience” then? If you take a look at the capabilities diagram again, things get pretty interesting:
TypeScript is a programming language that is a superset of JavaScript, so of course, there is still a possibility to write code that is correct at the runtime. However, in terms of the runtime errors, there is some middle ground between JS and Elm.
TypeScript lets you balance between not-as-predictable dynamic JS world and statically typed code.
This is a nice compromise because it creates an opportunity to warn the developer if the compiler is certain that something’s breaking at runtime. At the same time, there is always an escape hatch if you don’t want as much strictness for, let’s say, already existing JS codebase that you want to use without further effort. It depends on you, the developer, how much time you’re willing to invest in telling the TypeScript compiler about the code. So as a reward, you get some protection from the compiler that will tell you what could possibly go wrong before you even run the app.
If you look from the library and framework creators’ point of view, good use of TypeScript features is an opportunity to create APIs that developers won’t hate. So in this article, I’m going to prove TypeScript has the potential to take JavaScript to the heights of great Developer Experience by using some real-life examples and case studies. So let’s dive into the code!
Case study 1. The “average” function
The problem
Consider the following simple function that calculates the average for an array of numbers:
It works as expected for values like 5, 10, 15:
But, assuming that you are an “average calculation domain expert”, you know about some edge cases:
Because of an empty array, you’ll divide zero by zero.
You can easily imagine that some React apps using this library function will get confused when they get rendered “NaN”.
Runtime error approach
The first thing that probably pops into your mind is to fix this problem. You can use an error throwing mechanism to communicate the edge-case problem to the developer:
However, there’s a problem with this solution. TypeScript has no mechanisms that would force a developer to handle native JavaScript errors. What’s more, TypeScript doesn’t have native typed errors support at all. If you look at a “Promise” for instance, a successful path can be easily typed, but the error path is typed as “any”. This means that the aforementioned solution could make things even worse.
Let’s pause for a moment and think about using our function with that React app context in mind. Would it be a good developer experience to break the app that programmers are working on by throwing an exception because (in some component that summarises some data from some table) you miss numbers for the average function?
One of the good developer experience practices in libraries is “being a good citizen”. That means your library code shouldn’t break the context where it is used. Throwing an error that crashes your React app is clearly a case that breaks this rule. So, we’ll have to take a look at alternative approaches.
Functional Programming vs Errors
Let’s consider how typical JavaScript functions behave:
Functions in JavaScript can:
- receive and process arguments,
- return output value,
- perform side effects.
The last one, side effects, is something that can easily spin your code out of control. We had a good example of a side effect that can break the whole app in the previous section, namely, throwing an error is a side effect that can crash the whole app if the error is not caught.
For comparison, let’s look at how some of the functional programming languages deal with errors. In functional programming languages we tend to use pure functions that are closer to the mathematical definition of a function:
Namely, a pure function can’t have side effects. Like in math, you can think about it as a black box that maps argument values to other values, that’s all. But the question arises: how to deal with the errors if there are no side effects?
When you implement pure functions there are only two possibilities to communicate an error. If the only way to output something is the output value, then why won’t you simply return the error as an output value? When you revisit your code, you can do a simple, yet powerful change:
Did you notice how the “throw” keyword has been replaced with the “return” one?
Now, TypeScript is smart enough to deduce that function may not return a numeric value in all of the cases. What’s more, it can warn the developer where it is possible to mix the numbers against the error:
So, in such a case, developers are forced to handle the error the following way:
This trick however won’t protect you from the problem of having something that’s clearly not a number rendered in the user interface. Because React won’t protect you from rendering “EmptyArrayError” so in that case, you will just exchange “NaN” to “[Object object]”. No benefit here, perhaps, that “NaN” replacement looks even worse in this case.
We need to try something else…
Narrowed type approach
In the previous section, you’ve seen that pure functions have only two ways of communicating errors. We tried the “output value” way, what’s left then? We have no other option than try from the “arguments” side.
If you don’t want to throw or return an error at runtime, define your function in a way that it is impossible to call with arguments that introduce the error. In math, you can define a domain of a function. It means that some functions don’t have a sense for values that don’t belong to the defined domain. TypeScript has an opportunity to make use of that concept too. For example, you can define a type like this:
This works analogously to the built-in “Array<T>” type. However, the difference is that it assures an array of a type T having at least one element. You can use such type in your function definition:
When you test it, the happy path works like before:
But, when you try the edge case:
…you can clearly see that TypeScript protects you from writing code that doesn’t make sense:
There is still one problem though, in the real project, these numbers will probably come from the outside of your app (user input, backend response, etc.). So the compiler cannot foresee if the array that you want to use will be empty or not. But what you can do to address this problem is to define a constructor function for our “NonEmptyArray” type:
As you can see, empty arrays have to be handled somehow. In such cases, you can decide to return just “undefined” but that’s just a matter of taste what value you want to return for that case.
Let’s simulate the state where an array of numbers come from the world outside of our TypeScript app:
Then, you can make use of the type constructor function as follows:
And what’s cool, it warns developers if they want to do this:
This way, you can guide a developer that uses your library to implement the error handling as early as it could be:
At first glance, this pattern may look snippy. But in the long run, you protect the developers using your library from inconvenient debugging sessions on production, because you are honest with them about what your library requires to be able to work correctly. With time, users of your library will feel self-confident that it won’t break their app because they covered the edge-cases right from the start, on their IDE level.
Case study 2. The “delay” function
The problem
Let’s define a simple function creating a promise that is resolved with some delay:
Looks easy to use right? Well, for a programmer that knows JS by heart it might be pretty straightforward that such code will print “Hello” after 3 seconds:
But, if you are a person that spends tons of time writing some bash scripts where the analogous “sleep” function takes seconds as an argument, you may make a mistake like this one:
It is not 100% clear what units the delay function expects. We enforce the developer to remember what units they use and make room for ambiguities.
💡 It’s worth remembering that unit ambiguity has caused more than one catastrophe in the past. Read the article by my colleague below:
Nominal-types solution
To clear the ambiguities when it comes to units, the nominal-type features come to the rescue. However, at the moment of writing this article TypeScript has no native support for this feature.
What is nominal-typing? In our context, we can treat it as a type that we would like to distinguish on a compiler level, even if it is similar or equivalent to other types.
$100 (dollars) is not the same as 100€ (euros) although both are represented as a “number” equal to 100. By the way, it is also worth mentioning that the same thing can be called differently in other scopes, e.g. in flow these are opaque types.
So, TypeScript doesn’t support nominal types, right? Well, that’s only partially true. As I’ve mentioned, it doesn’t natively, but there are a couple of options for simulating this feature. My favourite hack looks like this:
After introducing such type, you can define type constructors:
…and some helper functions that make it possible to convert this type back to a standard number type:
Having these tools in your toolbox means you can rewrite the delay function that it will no longer have a problem with time unit ambiguity:
So, the developer wanting to use it, cannot provide just a number:
Instead, developers have to be explicit about the time unit:
…and that’s cool because you provide units that are much more convenient!
Value object solution
The nominal-type solution reminds me of something that is known in the Domain-Driven Design as a Value object. You can implement a code that works pretty much the same in the practice but is closer to the Object-Oriented paradigm:
The only difference is that in runtime you’ll pass a real object that wraps the numeric value. In contrast, the nominal-type case has just a primitive number value under the hood.
Case study 3. “useQuery” React hook
The problem
One of the common problems in frontend apps is fetching data from the backend. One way to approach this is to use hooks. So, let’s create a simple “useQuery” hook that will query the backend and handle the request state.
INB4: I’m not covering the runtime implementation itself so we can focus on the type-level of that code more.
The typical states this hook should return include:
{ isLoading: true, success: false }
– when request has just been sent and you wait for the response,{ isLoading: false, success: true, payload: { ... } }
– on successful response,{ isLoading: false, success: false, error: NetworkError }
– network error.
However, from the type level perspective, there’s nothing that stops the TypeScript compiler to treat these absurd states as possibilities too:
{ isLoading: true, success: true }
– request in progress, but is also successful, so what are we waiting for?{ isLoading: false, success: true, payload: { ... }, error: NetworkError }
– we’ve got success and error at the same time. This reminds me of the famous Shrödinger’s cat thought experiment.{ isLoading: true, success: true, payload: { ... }, error: NetworkError }
– loading + success + error combo.
All of these states match the ResponseState type that we’ve defined. In practice, if you write code that uses this hook:
Developers may not notice that this code won’t work correctly until they run the app. In practice, your hook needs some time to fetch the response, so the payload property that is reserved for the successful response won’t be available on the first component render. But, you’ve told the TypeScript compiler that payload is of type “any” for all possible states, hence TS compiler cannot help the developer here too much.
Perhaps there is some other way to tell TypeScript which states really do matter?
Tailor-made union types
Introducing, tailor-made union types! If you think about meaningful request statuses in your case, start with enum like:
Then, define each of the possible response states as a separate type. The simplest ones are for Idle and InProgress statuses:
However, a successful state is where things get interesting:
You can tie the payload property to be a unique property for the Success status only. Moreover, you can perform analogously for the Error status:
After defining all of these possible states one by one, you can combine all of them into one by using union type feature:
So, when you use your hook with a typed return value, TypeScript will warn developers that payload property may not exist:
Developers will be forced to prove that they got the correct response state before they are able to get to the payload property:
This makes a nice opportunity for code to be more reliable as other (not successful cases) have to be explicitly handled (or devs will be forced to at least think about them) before the app compiles successfully.
There is still one issue though. Have you noticed a bug in the aforementioned code? It is very subtle – a typo in the “comment.booy” part, it should be “comment.body” instead. I’ll cover that in the next section too.
Customization through generics
The problem with “any” type is that it turns the TypeScript code back to a typeless JavaScript world, where a variable could be anything. In such a case, you would have to tell how the “payload” really looks on a type-level instead of the not-so-strict “any” type. But you cannot hardcode that by defining the stricter payload type on your library level because your hook must be more generic and reusable for other backend endpoints that respond with different data structures.
Here’s where the generic types come to the rescue. Library authors often approach the problem of types customization by defining parameterizable generic types, so users of their libraries can sneak their own types in.
In this case, you can start by making the successful response generic:
Then, it needs to be propagated into places that make use of that type:
After doing this “generalization”, give the developer an option to pass their own types like follows:
And, ta-da: TypeScript will catch typos now:
100% type safety
If you value the type-safety even further, you may also want to have a guarantee that backend responses match your frontend app assumptions. In other words, defining a type like “Comment” won’t protect you from a backend that returns something completely different. If you want the developer to be 100% type-safe, you can force them to pass a response parser that ensures the data coming from the backend has the correct structure:
In case something’s wrong with the response data, the responseParser function can throw an error that can be caught and handled in your hook. For example, you can use zod library (which has a nice developer experience by the way) to define the response parser for your comments:
The powerful combination of generics and type inference is the underestimated feature in TypeScript. When you pass the defined parser:
…you can completely omit the generic parameter, as TypeScript will figure out the successful response payload from the parser type itself:
By the way! Elm programming language deals with guarantees vs backend communication compromise. Although the naming is a bit different because instead of calling it a parser, it calls it a JSON Decoder.
Developer experience in TypeScript. Summary
One of the most desired characteristics of a great developer experience is good documentation. But, what’s even better is documentation that is interactive and a design that guides the developer to the correct usage (of your API/library/code).
In contrast to overwhelming markdown books that teach how to use your product, you can do something extra and provide developers with a mentor that will guide them automatically through their use cases.
TypeScript is a powerful tool that can implement this approach thanks to some nice techniques that I’ve presented in this article. I hope you have been inspired to perceive TypeScript as something more than just a type system for JavaScript, and that you will enjoy using it as a tool that can fill some developer experience gaps.