skip to content
Nikolas Barwicki - Javascript Blog Nikolas's Blog

10 Javascript/Typescript features I avoid

/ 14 min read

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 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:

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. Here’s an example of how to use Zod to validate the user object and ensure that the street property always exists:

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 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:

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.

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, 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:

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:

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:

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 or type guards 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:

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:

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.

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.

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:

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:

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:

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:

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:

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:

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:

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.

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 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:

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.

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

type Size = keyof typeof Sizes
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 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:

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:

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 or higher-order functions. For example:

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.