DEV Community

Cover image for Master TypeScript Quality with This Essential Checklist
Aris Pattakos
Aris Pattakos

Posted on • Originally published at bestpractices.tech

Master TypeScript Quality with This Essential Checklist

When I started using TypeScript it felt really simple. But in a sense, it also felt like I was just writing JavaScript with some extra effort on top to maintain types. I couldn’t see all the value it had to offer, at least not immediately.

It took me some time to start realising the benefits of using TypeScript, and the things that I need to be careful with. After a lot of trial & error, I have learned how to make good use of TypeScript and it’s one of the most important tools that I have available.

Good TypeScript protects you from bugs and makes collaboration with others much easier. Bad TypeScript, on the other hand, can give you false confidence while allowing numerous bugs through.

Whenever I’m looking at TypeScript code, I ask myself the following questions to ensure that I’m making the best possible use of the language.

Am I silencing type checks?

TypeScript is designed as a superset of JavaScript. This means that a JavaScript program, with no typing rules at all, is still considered valid TypeScript. This design decision, means that you can easily have code that’s not type-checked within your TypeScript code.

This flexibility is useful when migrating from JavaScript to TypeScript. But when your project is built in TypeScript, you shouldn’t be silencing type checks because it defeats the purpose of using it.

You should use strict mode when building a TypeScript project, but even then there are ways to silence type checks:

  • Using any
  • Using // @ts-ignore
  • Making unsafe type assertions with x as unknown as MyType

Using any or // @ts-ignore silences type checks, and should rarely ever be used in production code. Type assertions have their uses, but you should make sure that using them is not just a way to silence type checking.

Does the Type represent a valid state?

Types should always represent something that can really happen inside of your code. A common symptom of this problems is when your types allow certain combinations of values that are theoretically impossible.

Let’s say we have 2 kinds of products in our app: shoes & t-shirts. This is a Type that you might often see in such cases, but there’s a problem with it. Can you spot it?

type Product = {
  kind: "tshirt" | "shoes";
  price: number;
  shoeSize?: number;
  tShirtSize?: 'S' | 'M' | 'L';
};
Enter fullscreen mode Exit fullscreen mode

As far as TypeScript is concerned, the product can have both shoeSize and tShirtSize available at the same time. Or the kind might be tshirt and tShirtSize might be missing. We know that these cases shouldn’t be possible based on our application logic, so how do we let TypeScript know that as well?

We’ll use Discriminated Unions to handle this case where one property (kind) gives us information about what other properties will be available.

type BaseProduct = {
  id: number;
  price: number;
}

type ShoesProduct = BaseProduct & {
  kind: 'shoes';
  shoeSize: number;
}

type TshirtProduct = BaseProduct & {
  kind: 'tshirt';
  tShirtSize: 'S' | 'M' | 'L';
}

type Product = ShoesProduct | TshirtProduct;

const product = getProductById(1);
if (product.kind === 'shoes') {
  // TypeScript knows that `product` is a `ShoesProduct` here
  // so we can safely access the `shoeSize` property.
  console.log(product.shoeSize);
}
Enter fullscreen mode Exit fullscreen mode

In the example above you can see how this no longer supports impossible sets of values, and the types closely match the application logic.

A lot of times you also have properties that are defined together and undefined together. Let’s say that you have a type called Photo in your codebase which might have a set of coordinates (lat & long). In this pair of values, you can either have both or none. Having just one of them defined is an impossible state.

type Photo = {
  url: string;
  lat?: number;
  long?: number;
};
Enter fullscreen mode Exit fullscreen mode

How might you fix the example above? Given that we know that lat and long follow each other, we can group them under a new property.

type Photo = {
  url: string;
  coordinates?: {
    lat: number;
    long: number;
  }
};
Enter fullscreen mode Exit fullscreen mode

This is much better now, as coordinates either has both values or none of them. This means that we turned an impossible state into one that matches our actual logic.

Is there redundant code?

DRY in software engineering stands for Don’t Repeat Yourself. As in any programming language or paradigm, TypeScript code can end up being overly wordy and contain redundant code.

A common reason for repeated code in TypeScript is not relying on inference. Inference is TypeScript’s ability to understand (infer) a lot of types automatically. Here are some examples of how you can use inference to avoid redundant types.

// Assuming you have a method that returns a User type
interface User {
  id: string;
  name: string;
}

class UserService {
  getUser: (id: string) => User;
}

const userService = new UserService();
const user: User = userService.getUser('123');
Enter fullscreen mode Exit fullscreen mode

In the example above, we’ve explicitly typed the user variable, but that was completely unnecessary. We can make our code easier to read by removing the explicit type.

const user = userService.getUser('123');
Enter fullscreen mode Exit fullscreen mode

Since getUser returns a User object, TypeScript knows the type that the user constant will have.

Let’s look at some more examples.

// ❌ The explicit return type is unnecessary
const isOddNumber = (number: number): boolean => number % 2 === 1;

// ✅ Since we're returning a condition, TypeScript
// already knows it's a boolean
const isOddNumber = (number: number) => number % 2 === 1;

// ❌ TypeScript already knows it's a string based on the definition
let name: string = 'Full Name';

// ✅ We can make our code tidier like this
let name = 'Full Name';
Enter fullscreen mode Exit fullscreen mode

Explicit types do have their uses. Sometimes you want code that’s short and easy to read, and other times you want to confirm that you’re returning the right thing. You will need to use your judgement as to which approach is suitable each time.

Is the Type as narrow as it can be?

Types are sets of possible values. When we define something as string it can be any string you can imagine, but it can’t be a number. The type string though is quite wide, and there may be cases where the possible set of values that we expect is much more narrow.

The possible status for an order, might be one of the values in this code example below.

// ✅ Using unions to define narrower
// types can be very helpful
type OrderStatus = 'Pending' | 'Processing' | 'Completed' | 'Cancelled';
Enter fullscreen mode Exit fullscreen mode

Using a union or an enum has a lot of upside, compared to defining status as a string:

  • The union or enum acts as documentation for other engineers to know the set of possible values.
  • TypeScript will alert you when there’s a typo, or when these values are compared with values that would never be equal (e.g. orderStatus === 'Pnding' could never be true because it’s not in the set)
  • Your editor will provide improved auto-complete suggestions.

Am I future-proofing my code?

One of the things I personally love about TypeScrips is the toolset that it provides for future-proofing your code. Let’s look at a JavaScript example to illustrate code that isn’t future-proofed.

const messageForRole = {
  admin: "Hello Admin",
  user: "Welcome back!",
};

const user = getUserById(1);
console.log(messageForRole[user.role]);
Enter fullscreen mode Exit fullscreen mode

What would happen if we added a new user role, say guest, but we forgot to change messageForRole? In this case we would obviously get an undefined value which can introduce bugs or cause runtime errors.

With TypeScript, we can create a type for roles and we can guard against problems like the one above. By using Record<UserRole, string>, we tell JavaScript that messageByRole must cover all roles to be a valid object. The error message protects from deploying faulty code to production and allows us to solve the issue easily.

type UserRole = 'admin' | 'user' | 'guest';
const messageByRole: Record<UserRole, string> = {
  admin: "Hello admin",
  user: "Welcome back"
};
// Property 'guest' is missing in type '{ admin: string; user: string; }'
// but required in type 'Record<UserRole, string>'.
Enter fullscreen mode Exit fullscreen mode

Other issues of this kind are harder to spot because they don’t lead to language errors, but logical errors. For example, if you expect an action to happen based on a set of conditions, JavaScript doesn't have a good way of telling you to add new actions whenever new conditions are introduced.

const sendMessageToUser = (user, message) => {
  if (user.communicationMethod === 'email') {
    console.log(`Emailing ${user.name}: ${message}`)
  }
  if (user.communicationMethod === 'sms') {
    console.log(`Texting ${user.name}: ${message}`)
  }
  // If 'telegram' was added, this function would just do nothing
}
Enter fullscreen mode Exit fullscreen mode

In this last example, if we added a new communicationMethod and didn’t update sendMessageToUser then the function would just do nothing! And that could be hard to spot because we wouldn’t see any errors immediately.

To account for the scenario above, which can occur quite often in real-life code, we can use exhaustive checks.

type User = {
  name: string;
  communicationMethod: "email" | "sms" | "telegram";
};

const assertNever = (value: never) => {
  throw new Error(`Unexpected value: ${value}`);
};

const sendMessageToUser = (user: User, message: string) => {
  switch (user.communicationMethod) {
    case "email":
      // sendEmail(user.name, message);
      break;
    case "sms":
      // sendSms(user.name, message);
      break;
    default:
      assertNever(user.communicationMethod);
    // Argument of type 'string' is not assignable to
    // parameter of type 'never'.
  }
};
Enter fullscreen mode Exit fullscreen mode

TypeScript is able to narrow down the possible values. If case "email" is not true, then TypeScript narrows down the type to “sms” or “telegram”. And then, if “sms” is also not true, then the only remaining option is “telegram”. Since we haven’t covered that case, we end up calling assertNever which expects a never type, with a string (“telegram”) which obviously TypeScript is not happy about.

This acts as useful reminder that forces you to check something in the future whenever certain underlying types change. You can use this approach to force yourself to think whenever a change happens.

Conclusion

TypeScript has a lot of fancy operators that can be useful to learn, but not all of them add a ton of value to your codebase. The questions and advice that I listed above are the ones that I believe are the most important to keep in mind when you’re think about the question “Is my TypeScript code good?”.

All of the examples here are simplistic, but they do illustrate how TypeScript can help you in various situations and how you can make the best possible use of it. I encourage you to try them in your personal projects or your work, and I’d love to hear your comments.

Top comments (7)

Collapse
 
greenteaisgreat profile image
Nathan G Bornstein

This couldn't have came at a more perfect time. I've been neglecting Typescript for so long and finally decided I need to start going with it. Seeing this post was divine JavaScript intervention 😌

Collapse
 
jangelodev profile image
João Angelo

Hi Aris Pattakos,
Fantastic, excellent content, very useful.
Thanks for sharing.

Collapse
 
dileepanipun profile image
Dileepa Nipun

Excellent, This is useful. Thank you for sharing. 🔥💯😎👌

Collapse
 
trantn profile image
Aidan

Thank you for sharing

Collapse
 
sayami007 profile image
Bibesh Manandhar

Thanks

Collapse
 
muinmundzir profile image
Muhammad Mu'in Mundzir

I see I can improve my code further more after reading this, thanks!

Collapse
 
theranjithkumar profile image
Ranjith Kumar

Excellent list!. Feel urged to amend code based on your suggestions.