I love TypeScript. From preventing undefined -related exceptions to allowing your text editor to give actually helpful hints, I cannot overstate how much time it ends up saving in the log run.
However, there are some pitfalls to be aware of, that could have you spend hours debugging if you are not familiar with them. Most of these issues are related to TS's type system being structural instead of nominal, meaning that whether two types are equal (or even one a subtype of the other) is determined by the properties of the types and not by how they were declared.
Let's see a basic example that can have some issues:
type User = {
name: string;
sensitiveInfo: string;
}
type NonSensitiveUser = Omit<User, 'sensitiveInfo'>
Let's suppose that we have an API that we want to make sure does not return any sensitive information. Easy, we annotate it with the NonSensitiveUser type right? Well, that won't be enough, since User is implicitly a subtype of NonSensitiveUser . The following code wouldn't throw any compile-time errors:
function fetchNonSensitiveUser(): NonSensitiveUser {
const user: User = fetchUser();
return user;
}
You might not think this is that bad. It can cause confusion, and leaves room for some unexpected security issues, but at the end of the day, User will behave as a NonSensitiveUser in pretty much any situation1, therefore this is unlikely to cause any type-related runtime exceptions, which is the main thing a type system aims to avoid.
Unfortunately, TypeScript can fail at that too. Let's consider the following types:
interface T1 {
foo: string;
}
interface T2 {
foo?: string;
}
Now we could write the following code, that fails because t1.foo is undefined, and TypeScript would show absolutetly no warnings:
function deleteFoo(bar: T2){
delete bar.foo;
}
const t1: T1 = {foo: 'value'}
deleteFoo(t1);
if(t1.foo.includes('ue')){ // Runtime error
Something similar can happen with union types, and even tuples:
type StringsOrNumbers = (string | number)[]
const numberArr: number[] = [1, 2, 3];
function pushHelloWorld(arr: StringsOrNumbers){
arr.push('Hello World');
}
pushHelloWorld(numberArr);
// numberArr now has a string
type Tuple = [number, number]
const myTuple: Tuple = [ 42, 43 ];
function pop(arr: number[]){
return arr.pop();
}
pop(myTuple)
myTuple[1] // undefined
What's the takeaway here? Is TypeScript evil and to be avoided? Certainly not. But you have to be aware of these drawbacks to be able to take full advantage of its type safety. Some things that can help prevent these hazards are:
- Avoid explicit function return types when possible. In the first example, the function would have been properly typed as User had it not been for the explicit typing. However, there's an argument to be made that this may hurt readability.
- Avoid mutating function arguments whenever possible, especially if you're writing library code. This is probably a good idea even in vanilla JavaScript, unless there's a very good reason to do so2, but it is especially relevant in TypeScript, as seen in these examples.
- If mutation is unavoidable, make it very clear, both in the docs and in the function's name if possible.
- Keep in mind that TypeScript's type system is not infalible, and when a weird error happens, double check the actual types of your variables using a debugger.
Update (Aug-2024):
I recently found out about Flow, a (mostly) sound alternative to TypeScript.
To achieve this, Flow:
- Limits type variance of mutable types (meaning you cannot cast Array<string> to Array<string | number> , but you can cast it to $ReadOnlyArray<string | number> )
- Uses refinement invalidation to account for functions with side effects
There's obviously a trade-off here between type-safety and ergonomics, but I can see this being useful in more error-critical cases (though in such cases, you may also consider switching to a non-JS-based language, specifically to a language with errors as values to protect you against uncaught exceptions, and you might as well enjoy the extra speed that comes from a truly statically-typed language (at least if you don't have to run it in a browser, or if you can compile to WASM))