10 Javascript/Typescript features I avoid
Tags: javascript, typescript, discuss
Published: 2023-05-07 10:00:00

> Also available on my [blog](https://nikolasbarwicki.com/articles/10-javascript-typescript-features-i-avoid)

## Introduction

In this article, we explore various features and practices in Typescript and Javascript that can potentially lead to **errors**, reduced **type safety**, and **maintainability issues**.

By understanding the drawbacks of using these features, developers can make informed decisions to avoid pitfalls and write more robust, maintainable code. Some of the discussed topics include optional chaining, optional fields, type assertion, `var` and `let`, `any`, `else`, `class`, `enum`, and `switch` statements.

## Optional Chaining (?.)

[Optional chaining](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining) is a feature that allows developers to access nested properties **without having to check if each level of nesting exists**. It can be useful in certain scenarios but can also lead to _unexpected_ behavior if not used carefully.

### Example:
Optional chaining implies **uncertainty** about the existence of a property, which could lead to unexpected errors or undefined values. Consider the following code snippet:

```typescript
const user = {
  name: 'John',
  address: {
    street: '123 Main St',
  },
}

console.log(user?.address?.city); // undefined
```
In the example above, `user` has an `address` object but no `city` property. The use of optional chaining here implies that it is acceptable for `city` to be `undefined`, which could lead to **unexpected behavior** further down the code.

### Solution:
To avoid such issues, it's recommended to **validate your data** and use dedicated types for different permutations of data. One way to achieve this is by using a schema validation library like [Zod](https://github.com/colinhacks/zod). Here's an example of how to use Zod to validate the `user` object and ensure that the `street` property always exists:

```typescript
import * as z from 'zod'

const User = z.object({
  name: z.string(),
  address: z.object({
    street: z.string(),
  }),
})

const user = User.parse({
  name: 'John',
  address: {
    street: '123 Main St',
  },
})

console.log(user.address.street) // '123 Main St'
```

By using a validation library like Zod, you can ensure that your data always **conforms to a specific schema** and avoid unexpected behavior caused by `undefined` values. It's also recommended to use dedicated types for different permutations of data to ensure type safety and prevent errors during runtime.

## Optional Fields (?:)

[Optional field in Typescript](https://www.typescriptlang.org/docs/handbook/2/objects.html#optional-properties) allow a field to be marked as potentially `undefined` or **missing**, indicated by the `?` symbol after the field name. While it can be convenient to use this feature in some cases, it can also be **harmful to your code** in several ways.

### Example:

Consider the following interface for a user:

```typescript
interface User {
  id: number
  name?: string
  email?: string
}
```

In this case, both the `name` and `email` fields are **optional**. However, this can lead to issues if any part of the code is **expecting them to be defined**.

For example, imagine a function that sends an email to the user. If the email is missing, it could cause an error that's difficult to track down.

### Solution:

Instead of using optional fields, a safer approach is to declare **separate, well-named types for different cases**. For example, you can create a type for a user with an email and another type for a user without an email. This makes it clear when a field should or shouldn't exist and helps prevent errors in your code.

```typescript
interface UserWithEmail {
  id: number
  name: string
  email: string
}

interface UserWithoutEmail {
  id: number
  name: string
}
```

By using separate types, you can also improve type safety in your code. For example, if you have a function that only works with users that have an email, you can define its argument type as `UserWithEmail` instead of `User`. This ensures that the function only **receives the data it needs** and prevents potential issues with undefined fields.

Overall, it's important to be mindful of the use of optional fields in Typescript, as they can lead to errors and reduce type safety. Instead, consider using **separate types for different cases** to improve code clarity and reduce potential issues.

## Type Assertion (as)

[Type Assertion](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions), also known as type casting, is a Typescript feature that allows the developer to tell the compiler about the **expected type of a variable or expression**. It is done by using the `as` keyword followed by the target type, like this: `const num = someValue as number;`. However, using type assertion **can lead to errors if you make the wrong assumption about the type of the variable or expression**.

### Example:

Consider the following example, where we have an interface for a person and an array of objects that implement that interface:

```typescript
interface Person {
  name: string
  age: number
}

const people: any[] = [
  { name: 'Alice', age: 30 },
  { name: 'Bob', age: '40' },
  { name: 'Charlie', age: 50 },
]
```

If we try to access the `age` property of each object in the array, we'll get a **compilation error** because Typescript doesn't know the type of the `age` property:

```typescript
people.forEach((person) => {
  console.log(person.age) // Error: Property 'age' does not exist on type 'any'.
})
```

To fix the error, we could use Type Assertion to tell Typescript that the age property is a number, like this:

```typescript
people.forEach((person) => {
  console.log((person.age as number).toFixed(0)) // No error, but runtime error: '40' is not a number.
})
```

However, this code **will throw a runtime error** because we assumed that `age` is always a `number`, but it's actually a `string` for the second object in the array.

Type Assertion can be harmful because it makes your code **less type-safe**. If you use Type Assertion, you're telling Typescript to trust your judgment **without checking if it's correct**, which can lead to runtime errors.

###  Solution:

Instead of using Type Assertion, we can use [type narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) or [type guards](https://nikolasbarwicki.com/articles/typescript-type-guards-how-to-use-them) to ensure correct types. Type narrowing is a technique to tell Typescript to narrow down the type of a variable based on some condition. We can use the `typeof` operator to check the type of a variable, like this:

```typescript
people.forEach((person) => {
  if (typeof person.age === 'number') {
    console.log(person.age.toFixed(0)) // No error.
  }
})
```

This code will only execute the `console.log` statement if `person.age` is a `number`, ensuring that we don't get a runtime error. Alternatively, we could use a type guard function to check the type of `person.age` and return a `boolean` indicating if it's a number or not:

```typescript
function isNumber(value: any): value is number {
  return typeof value === 'number'
}

people.forEach((person) => {
  if (isNumber(person.age)) {
    console.log(person.age.toFixed(0)) // No error.
  }
})
```

This code achieves the same result as the previous example, but using a **reusable type guard function**. By using type narrowing or type guards, we can ensure that our code is type-safe and avoid the risks of Type Assertion.

## Var and Let

`var` and `let` are used to declare variables in Javascript/Typescript, but they can encourage **mutable code** and provide **less information** to the reader.

### Example:
Let's say you have a variable `x` that needs to be incremented in a loop. Using `let` to declare `x` would allow its value to be **reassigned**, which can lead to unpredictable results.

```javascript
for (let x = 0; x < 10; x++) {
  // some code here
}

console.log(x) // error: x is not defined
```

In the example above, `x` is only accessible within the loop because of the `let` declaration, which **limits the scope of the variable**. However, if you accidentally try to access `x` outside of the loop, you'll get a reference error because `x` is not defined.

Why avoid them? Using `const` instead makes it clear that the variable's value won't be reassigned. `const` declarations are also block-scoped, which means they can only be accessed within the block they were declared in. This helps to **prevent accidental changes** to the variable's value.

### Solution:
Use `const` for variable declarations whenever possible. If you need to reassign the variable's value, consider using a different variable name or restructuring your code to avoid reassignment altogether.

```javascript
const user = { name: 'John', age: 30 }
user.age = 31 // ok
user = { name: 'Jane', age: 31 } // error: assignment to constant variable
```

In the example above, we use `const` to declare `user`, which makes it clear that its value won't be reassigned. However, we can still modify the properties of the `user` object without any issues. If we try to assign a new object to `user`, we'll get an error because user is a constant variable.

## Any

The `any` type is a feature in Typescript that can be used to declare a variable whose **type is not known or not important to the current**. However, it can be a bad practice to use `any` excessively in your code, as it **undermines the benefits** of Typescript's type checking system.

### Example:

```typescript
function multiply(x: any, y: any) {
  return x * y
}

const result = multiply('2', 3) // returns '22'
```

Here, the `any` type is used for both `x` and `y`, which means that Typescript won't check the types of the arguments passed into the `multiply` function. As a result, the function **returns an unexpected result**, as the string `'2'` is concatenated with the number `3` instead of being multiplied, due to Javascript's dynamic **type coercion**.

### Solution:

Instead of using `any`, you can declare a specific type for your data, which will help Typescript **catch potential errors before they occur**. For example, you can declare a type for your function's arguments like this:

```typescript
function multiply(x: number, y: number) {
  return x * y
}

const result = multiply('2', 3) // error: Argument of type 'string' is not assignable to parameter of type 'number'
```

By declaring specific types for the function arguments, Typescript can now catch the type error and **prevent the unexpected behavior**. In general, it's recommended to use `any` sparingly and only when necessary, as it can make your code **more error-prone** and **harder to maintain** over time.

## Else

The `else` keyword is often a sign of mutable code, which should be avoided in favor of immutable patterns. This is because using else can lead to code that is harder to read and maintain. For example, consider the following code snippet:

```javascript
function getUserRole(user) {
  if (user.isAdmin) {
    return 'Admin'
  } else {
    return 'User'
  }
}
```

In this code, we use else to handle the case where the user is not an admin. However, this can be rewritten using a single expression, making the code more concise and easier to read:

```javascript
function getUserRole(user) {
  return user.isAdmin ? 'Admin' : 'User'
}
```

Another example of when using else can be harmful is when checking for the existence of a value in an array:

```javascript
function findIndex(arr, value) {
  let index
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] === value) {
      index = i
      break
    } else {
      index = -1
    }
  }
  return index
}
```

In this code, we use else to set the index to -1 if the value is not found in the array. However, this can be simplified using the Array.indexOf() method, which returns -1 if the value is not found:

```javascript
function findIndex(arr, value) {
  return arr.indexOf(value)
}
```

Solution: Use a single expression, such as a ternary operator, or a method that returns the desired result. By avoiding else and using immutable patterns, we can write code that is easier to read, maintain, and reason about.

## Class

Classes are one of the most widely used features in Javascript, especially when building larger applications. However, classes can be confusing and error-prone compared to functions. One major problem is that Javascript classes are essentially syntactic sugar over the existing prototypal inheritance model. This can lead to unexpected behavior, especially when subclassing.

Example:

```javascript
class Animal {
  constructor(name) {
    this.name = name
  }

  speak() {
    console.log(`${this.name} makes a noise.`)
  }
}

class Dog extends Animal {
  constructor(name) {
    super(name)
  }

  speak() {
    console.log(`${this.name} barks.`)
  }
}

const dog = new Dog('Rufus')
dog.speak() // output: "Rufus barks."
```

In the above example, we have two classes, Animal and Dog. Dog extends Animal and overrides the speak method. While this works as expected, the downside is that the class hierarchy can quickly become complicated and difficult to reason about, especially as the application grows larger.

Solution:
Instead of relying on classes, we can write mostly pure functions. Pure functions are functions that always return the same output given the same input and do not have any side effects. They are more composable, easier to test, and generally preferable.

```javascript
function createUser(name, age) {
  return { name, age }
}
```

In the above example, we have a pure function createUser that takes in name and age as arguments and returns an object with those properties. This function is simple, composable, and easy to reason about. It has no side effects, so we can test it with confidence.

In general, it's important to choose the right tool for the job. While classes can be useful in some cases, it's often better to rely on simpler and more composable solutions. Pure functions are one such solution that can help make our code more maintainable and less error-prone.

## Enum

[Enums](https://www.typescriptlang.org/docs/handbook/enums.html) are a feature in Typescript that allow you to define a **set of named constants**. However, they have some drawbacks and can often be replaced with better alternatives.

### Example:

Using enums can make your code **less readable** and **harder to maintain**. Enums generate **extra code at runtime** and can cause performance issues in large-scale applications. They can also lead to **namespace pollution** if not used carefully.

Consider the following enum:

```typescript
enum Size {
  Small,
  Medium,
  Large,
}
```

Using this enum may seem like a good idea, but it has **some problems**. For example, it **doesn't provide type safety**. You can assign any value to it, including non-numeric values like strings or booleans. Additionally, enums can be hard to extend or refactor without breaking existing code.

### Solution:
Instead of using enums, you can use **plain objects** or **string unions derived from arrays marked as const**. This approach is more efficient, easier to read, and less prone to errors.

```typescript
const Sizes = {
  Small: 1,
  Medium: 2,
  Large: 3,
} as const

type Size = keyof typeof Sizes
```

```typescript
const Sizes = ['Small', 'Medium', 'Large'] as const

type Size = (typeof Sizes)[number]
```

By using plain objects or string unions, you get **type safety**, **better performance**, and **cleaner code**. It's also easier to extend or refactor your code without breaking existing code.

## Switch

[The "switch" statement](https://javascript.info/switch) is a control flow statement that executes a code block based on the matching case label. However, it can be **unnecessarily complex** and **reduce type safety** if not exhaustive. Also, a switch statement can be a bad practice because it leads to code duplication and can be difficult to maintain.

### Example:

```javascript
function getAnimalSound(animal) {
  switch (animal) {
    case 'cat':
      return 'meow'
    case 'dog':
      return 'woof'
  }
}
```

In this example, if a new animal sound needs to be added, a new case label should be added to the switch statement. But if the developer forgets to add the new case label, the function will return `undefined`, leading to unexpected behavior. Additionally, if the developer needs to use this function in different parts of the application, they will need to duplicate the switch statement code.

### Solution:
Use a **Typescript Record with a union** to enforce exhaustive checks, or invert control to eliminate the switch altogether.

Using a Typescript Record with a union can **eliminate the need for switch statements** altogether. The idea is to define a type that lists all possible values that the variable can take, and then use a Record to define a mapping from each value to a corresponding function that will handle it. For example:

```typescript
type Animal = 'cat' | 'dog'

type AnimalSoundHandler = {
  [K in Animal]: () => string
}

const animalSoundHandlers: AnimalSoundHandler = {
  cat: () => 'meow',
  dog: () => 'woof',
}

function getAnimalSound(animal: Animal) {
  return animalSoundHandlers[animal]()
}
```

In this example, the `Animal` type defines all possible animal values that the function can take. Then, the `AnimalSoundHandler` type uses a Record to map each value of the Animal type to a function that returns the corresponding animal sound. This approach enforces **exhaustive checks at compile time**, ensuring that all possible animal values are handled.

Alternatively, **inverting control** can eliminate the need for switch statements by delegating responsibility for the decision to the caller. This can be done using the [Strategy pattern](https://refactoring.guru/design-patterns/strategy/typescript/example) or [higher-order functions](https://eloquentjavascript.net/05_higher_order.html). For example:

```typescript
function getAnimalSound(animal: string, soundFn: (animal: string) => string) {
  return soundFn(animal)
}

function catSound(animal: string) {
  return animal === 'cat' ? 'meow' : ''
}

function dogSound(animal: string) {
  return animal === 'dog' ? 'woof' : ''
}

getAnimalSound('cat', catSound) // 'meow'
getAnimalSound('dog', dogSound) // 'woof'
```

In this example, the `getAnimalSound` function delegates responsibility for determining the animal sound to the `soundFn` function. The `soundFn` function takes an animal parameter and returns the corresponding animal sound. The caller can pass in different `soundFn` functions to get different animal sounds, **eliminating** the need for switch statements.

In conclusion, while switch statements are **not always a problem**, they can be unnecessarily complex and reduce type safety if not exhaustive.

## Summary
In conclusion, being aware of potentially harmful features and practices in Typescript and Javascript is essential for writing clean, maintainable, and error-free code. By opting for alternative approaches and adhering to best practices, developers can ensure their applications are more robust, easier to test, and less prone to unexpected behavior. By continuously updating our knowledge and refining our coding habits, we can create a better foundation for our projects and increase the overall quality of the software we develop.