In this article, we will discuss 10 bad habits when programming in TypeScript that we should avoid. We will inform what the bad habit is, why it is common to have it, and how we can avoid it.
This article is a translation of the original article in English, written by Daniel Bartholomae. You can read the original article by clicking here. We hope you enjoy it!
1 - Not using strict mode
How the code may look
We could create the tsconfig.json file without defining strict mode:
{
"compilerOptions": {
"target": "ES2015",
"module": "commonjs"
}
}
How the code should look
We should simply enable strict mode:
{
"compilerOptions": {
"target": "ES2015",
"module": "commonjs",
"strict": true
}
}
Why do we have this habit?
Introducing strict rules to an existing codebase takes time. Therefore, we may not want to enable this mode.
Why should we avoid this habit?
Stricter rules will make it easier to change the code in the future. Thus, it will be worth the time we spend fixing errors, as we will be greatly rewarded when we have to work with this code in a repository in the future.
2 - Defining default values with the || operator
How the code may look
We could define optional values using the || operator:
function createBlogPost (text: string, author: string, date?: Date) {
return {
text: text,
author: author,
date: date || new Date()
}
}
How the code should look
We can use the new ?? operator, or, even better, define default values at the time of parameter declaration:
function createBlogPost (text: string, author: string, date: Date = new Date()) {
return {
text: text,
author: author,
date: date
}
}
Why do we have this habit?
The ?? operator is a new feature that was introduced recently, last year. Additionally, when we are working with a very large function and need to use the values in the middle of it, it can be challenging to define that information as default values in the parameter declaration.
Why should we avoid this habit?
The ?? operator, unlike the || operator, is triggered only for null and undefined values, and not for any other value that could be identified as false. Moreover, if your functions are so long that you cannot define default values in the parameter declaration, splitting or “breaking” them into smaller functions may be a good idea.
3 - Using the any type as a variable type
How the code may look
We may decide to use the any type for variables where we are unsure about the format of their information, such as a variable that expects a return from an API:
async function loadProducts(): Promise<Product[]> {
const response = await fetch('https://api.mysite.com/products')
const products: any = await response.json()
return products
}
How the code should look
In most cases where we define a variable with the any type, we should replace it with the unknown type:
async function loadProducts(): Promise<Product[]> {
const response = await fetch('https://api.mysite.com/products')
const products: unknown = await response.json()
return products as Product[]
}
Why do we have this habit?
The any type is very convenient, as it essentially disables all type checks, preventing errors from being caught during the TypeScript compilation. Additionally, the any type is often used even in official type definitions (for example, response.json() in the example above is typed as Promise
Why should we avoid this habit?
This type essentially disables all type checks. Any variable created with the any type will be ignored by type checking. This makes it very difficult to find bugs, as the code will fail only when our assumptions about the structure of types are relevant to the compiler at runtime.
4 - Defining the return of a value with as
How the code may look
The code may be informing the compiler of a type it cannot deduce (infer):
async function loadProducts(): Promise<Product[]> {
const response = await fetch('https://api.mysite.com/products')
const products: unknown = await response.json()
return products as Product[]
}
How the code should look
In this case, you can use type guards:
function isArrayOfProducts (obj: unknown): obj is Product[] {
return Array.isArray(obj) && obj.every(isProduct)
}
function isProduct (obj: unknown): obj is Product {
return obj != null
&& typeof (obj as Product).id === 'string'
}
async function loadProducts(): Promise<Product[]> {
const response = await fetch('https://api.mysite.com/products')
const products: unknown = await response.json()
if (!isArrayOfProducts(products)) {
throw new TypeError('Received malformed products API response')
}
return products
}
Why do we have this habit?
When converting JavaScript code to TypeScript, the existing codebase often makes assumptions about types that cannot be automatically identified by the TypeScript compiler. In such cases, informing a different value through as OtherType may speed up the conversion without needing to change the settings in the tsconfig.json file.
Why should we avoid this habit?
Even though defining types may be a quick fix, it can change when someone moves the code. Type guards will ensure that all type checks are explicit.
5 - Using as any in tests
How the code may look
It may happen that we create an incomplete structure for our code tests:
interface User {
id: string
firstName: string
lastName: string
email: string
}
test('createEmailText returns text that greets the user by first name', () => {
const user: User = {
firstName: 'John'
} as any
expect(createEmailText(user)).toContain(user.firstName)
}
How the code should look
If you need to simulate data for our tests, it’s better to move the simulation logic closer to the structure we want to simulate and make it reusable:
interface User {
id: string
firstName: string
lastName: string
email: string
}
class MockUser implements User {
id = 'id'
firstName = 'John'
lastName = 'Doe'
email = '[email protected]'
}
test('createEmailText returns text that greets the user by first name', () => {
const user = new MockUser()
expect(createEmailText(user)).toContain(user.firstName)
}
Why do we have this habit?
When we write tests on a codebase that still lacks extensive test coverage, we often find large data structures, but only parts of them are necessary for the specific functionality being tested. Therefore, we do not need to worry about the other properties in the short term.
Why should we avoid this habit?
Abandoning the creation of a data mock will affect us in the future when one of the properties of our structure changes, and we need to make a change in all tests, instead of just one place. This can be a hassle. Furthermore, there will be situations where the tested code will depend on properties that we did not consider important before, and because of that, it will be necessary to change all tests for that functionality.
6 - Optional properties
How the code may look
We may create properties as optional that exist in some cases, and in others do not:
interface Product {
id: string
type: 'digital' | 'physical'
weightInKg?: number
sizeInMb?: number
}
How the code should look
We should explicitly define the combinations that exist and those that do not:
interface Product {
id: string
type: 'digital' | 'physical'
}
interface DigitalProduct extends Product {
type: 'digital'
sizeInMb: number
}
interface PhysicalProduct extends Product {
type: 'physical'
weightInKg: number
}
Why do we have this habit?
Defining properties as optional instead of separating types into more interfaces is easier and results in less code. This also requires a deeper understanding of the product being made and can limit the use of the code if assumptions about the product change.
Why should we avoid this habit?
The great benefit of type systems is that they can replace runtime checks with compile-time checks. By defining types more explicitly, it is possible to access compile-time checks for bugs that might otherwise go unnoticed. For example, ensuring that every DigitalProduct interface has a sizeInMb property.
7 - Naming generics with just one letter
How the code may look
Perhaps we have the habit of creating generics using just one letter:
function head<T> (arr: T[]): T | undefined {
return arr[0]
}
How the code should look
It’s better to define a name that fully describes the generic context:
function head<Element> (arr: Element[]): Element | undefined {
return arr[0]
}
Why do we have this habit?
This habit grew because even the official TypeScript documentation uses a letter to create generics. Additionally, it is faster and we do not need to think as much just to press the T key than to write a full name.
Why should we avoid this habit?
Type variables in generics are variables like any other. We abandoned the idea of describing the technical details of variables in their names when IDEs began to show us those details. For example, instead of writing const strName = 'Daniel', we simply write const name = 'Daniel'. Furthermore, variables created with just one letter are discouraged, as it can be difficult to understand their purpose without looking at their declaration.
8 - Performing non-boolean boolean checks
How the code may look
We may have the habit of checking if a variable is defined just by putting its value in an if() statement:
function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}
How the code should look
We should explicitly inform the condition we are expecting:
function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages !== undefined) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}
Why do we have this habit?
Writing the condition we want to perform in a concise way is more succinct and helps us avoid overthinking what we really want to check.
Why should we avoid this habit?
Perhaps we should think about what we really want to verify. The above examples, for instance, handle the case where the variable countOfNewMessages is 0 in a different way.
9 - The “Bang Bang” operator
How the code may look
We may try to convert a non-boolean value to boolean:
function createNewMessagesResponse (countOfNewMessages?: number) {
if (!!countOfNewMessages) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}
How the code should look
We should explicitly inform the condition we are expecting:
function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages !== undefined) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}
Why do we have this habit?
In some people's opinions, we can only say we are really starting with JavaScript when we can understand the !! operator. It is very short and succinct, and if you have used it a few times or are accustomed to using it, you know from experience that it can be very useful. It's a shortcut that allows us to convert any value to boolean. This can be especially useful in code where there isn't a clear distinction between values that can be considered false, such as null, undefined, and “”.
Why should we avoid this habit?
Like many other shortcuts, using !! obscures the true intent of the code, which necessitates greater internal knowledge to understand it. This makes the codebase less accessible to new programmers, whether they are new to programming as a whole or just new to the JavaScript language. Additionally, this habit can subtly introduce bugs into our code. The issue with the countOfNewMessages variable being 0 that we demonstrated in the last topic remains with !!.
10 - != null
How the code may look
The != null structure, the younger sister of the “Bang Bang” Operator, allows us to check for both null and undefined values at the same time:
function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages != null) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}
How the code should look
We should explicitly inform the condition we are expecting:
function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages !== undefined) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}
Why do we have this habit?
If you made it this far, your codebase and developer skills are surely in good shape. Even most linting tools that reinforce the use of the !== operator instead of != make an exception for the != null structure. If there isn’t a clear distinction in code about the difference between null and undefined, using != null helps us check both possibilities in a shorter way.
Why should we avoid this habit?
Although null values were a nuisance in early JavaScript, with the use of TypeScript in strict mode, these values can become a valuable member of the language's utility belt. A pattern I have seen is defining null values as things that do not exist, and undefined as unknown values. For example, user.firstName === null may mean the user literally has no first name, while user.firstName === undefined means we just haven't asked that from the user yet (and user.firstName === “” could mean that the first name is literally an empty string).
Conclusion
In this article, we saw some practices that we may have developed over time and that can be harmful. When we understand in which situations they may subtly harm us, it becomes easier to accept the change we need to make.
We know that programming is a constant improvement of our techniques, and by paying attention to them, we become better and better programmers. And if you want to learn or improve your TypeScript, be sure to sign up for our course Mastering TypeScript, where we teach all the concepts of this amazing superset ?.
Until the next article!