by Jannis Jorre, 11.08.2021
If you've been using TypeScript for your web development, you have understood the value that static typing brings to your productivity. However, TypeScript still has some issues in regards to type safety. This article is supposed to show these issues and help you see how another statically typed language - Haskell - is solving them. In the end, I hope that you will have learned something, and might be interested in continuing your journey into type-safe programming by trying out Haskell for your next project. If you choose to do so, I recommend using IHP, a productivity-focused web framework with which you can build full-stack web-apps (but APIs work just fine as well).
The anti-pattern that is any
The any
type included in TypeScript can cause many issues, as it straight up disables typechecking for whatever it's used for. Once you use it, you might as well write plain JavaScript. But it might actually be worse than that: people might be highly confident in their refactoring or new code if they don't get any type errors. What they might not notice is that an any
somewhere else in the code where they didn't change anything is not throwing a type error, although it would if the correct type were used.
So why would anyone use any
? I think there's a few different reasons:
- the correct type is not known to the developer at the time of writing,
- the correct type would be too complex, or
- it's implicitly used by TypeScript because it can't figure out the correct type by itself (this one can be disabled using the
noImplicitAny
setting)
Now, if the correct type is not known by the developer, that could either mean it'd be useful for them to do some research into what it actually is, or, more likely, the type cannot be known at that point in time. In any case using TypeScript's unknown
type is much better, as that is a type-safe way of accepting any value, that also disallows any interaction with the value that might be problematic. This is the type to use in case of migrating an existing JS codebase to TypeScript, as it keeps the guards of TypeScript intact.
If the correct type is too complex for the developer to figure out or to bother typing, and TypeScript isn't able to do so either, there's little you can do except use unknown
again, which definitely isn't ideal. The developer probably already understands things about the type, but using unknown
means that TypeScript will not, which will just lead to unnecessary checks.
In case Haskell was used instead, the likelihood of Haskell figuring out the type by itself (this is called type inference btw) is much higher. This is because in Haskell any
isn't even an option, so it can't confuse the compiler. But also, ghc - the most widespread Haskell compiler - has years and years of effort behind it. And lastly, as long as you stick to basic Haskell, it is specifically designed to enable type inference.
Limitations of being a super-set of JavaScript
TypeScript has the core design-goal of being a super-set of JavaScript. What that means is that any JavaScript code is valid TypeScript code as well (if the transpiler is configured to accept it), and you only need to add code to add more definitive types to the code. This is great if you need to convert an existing codebase from JavaScript to TypeScript, as it allows you to work in very small increments.
The problem is that new projects built using TypeScript still suffer from many of the problems of JavaScript:
- difficult-to-understand behavior that doesn't behave like in other languages (for example the `this` keyword)
- many things on https://wtfjs.com/
So while I applaud converting an existing JavaScript codebase to TypeScript, I would never start a new project in either if there's good alternatives, which there always are for backend development. Also, using the right tools the need for a lot of frontend code can be eliminated, while maintaining a highly-interactive webapp. Check out IHP for my favorite solution right now.
Being a super-set of JavaScript, many type definitions are a lot more complex to write than they would otherwise need to be as well. Check out the following examples, which are type definitions for equivalent Haskell and TypeScript code:
// A function that takes a tuple of two values and returns the first: // TypeScript type first = ([a, b]: [A, B]) => A // Haskell first :: (a, b) -> a
// a map function for lists/arrays // TypeScript type map = ((a: A) => B, list: A[]) => B[] // Haskell map :: (a -> b) -> [a] -> [b]
TypeScript can't type everything
If you're using TypeScript you see the advantages of having static typing available, so we don't need to go over them. As such, it should bother you whenever you cannot actually use static types to encode useful information. Here are a few cases in which TypeScript cannot actually fulfill its promise of type-safety, and the way that Haskell solves it.
Side-Effects
If you've been following recent trends in software development, especially in the JavaScript world, you know that functional programming principles are becoming more and more popular. React is built on them! One of the things people are advocating for are "pure functions": functions that, given the same arguments, always return the same result and that don't have side-effects. What makes these functions so great is that they are very easy to reason about, and there are various techniques for performance optimization that are much easier using them, if not impossible without them.
When coding in TypeScript, figuring out whether or not a function is pure requires you to read its source code and figure out how it works, which might lead to other functions that you have to do the same thing for. This gets tedious and could easily be avoided if the information of function purity was included in the function's type.
In Haskell, this is done by making every function pure by-default. Whenever you need to have a side-effect in a function, you need to declare this on the type-level. Not doing so is a compiler error.
Failures
As long as everything works as expected, TypeScript works just fine. However, when something goes wrong, you will likely have to interact with an error that gives you more information. Before you can even do so though, you need to be aware that an error can occur - and this is one of the shortcomings of TypeScript. It is impossible to declare on the type-level that a function might throw an error. This means you still need rigorous testing to figure this out, and even then you might run into unhandled errors in production due to not having tested thoroughly enough.
In Haskell it is most common to force handling errors by making the return type include the information of whether or not the function succeeded or not. Depending on the actual function, you might get no, some, or a lot of information in case of a failure, but you can be sure that you are handling these cases. The two most common ways of representing the option for failure are the Maybe
and Either
types.
The Maybe
type can represent success and failure by containing the success value in the success case, or Nothing
in the case of a failure. This is also basically Haskell's way of handling null
and undefined
, which prevents from using these values in a way that they don't support, which might cause issues.
The Either
type works in a very similar way: in the case of a success it simply contains the value that the function would return anyways. But in case of a failure it contains some other value. The type of this is clearly defined, which means that there's no ambiguity in how to handle it.
What's best about these techniques is that it does not only allow you to handle the error case in a type-safe fashion, it forces you to handle error cases where they might occur. If the function doesn't force you to handle the error case, no error can occur.
One place where typing the failure state should clearly be possible are promises, however TypeScript has no default way of doing this, which means that in case you run into a failure in your asynchronous code, TypeScript is not going to help you in figuring out the value you got in place of the success value.
Conclusion
TypeScript is a great way to improve an existing JavaScript codebase, and to bring a bit more productivity and safety to your frontend code. However, the language has issues, some of which are so ingrained in the language itself that they are not going to be solved with time. So whenever you can, try opting for a language that has less design issues at its core, such as Haskell, and avoid writing JavaScript/TypeScript wherever possible.
To allow you to do this, we at digitally induced have written IHP - an opinionated full-stack web framework based on Haskell, with a focus on developer experience and productivity, without sacrificing user experience. To try it out for your next full-stack project or backend, get started using the Guide and join our community forum or active slack workspace to get any support you might need. We try to help wherever we can.
Do you have any thoughts on TypeScript that you want to share? Did I miss anything? I'd love to hear your opinions and thoughts!